gunnar-sendpack 1.1.0

git's receive-pack wire format, both ends: the send-pack conversation gitoxide does not have, plus the server-side encoders for the same grammar. Plumbing only, no gunnar types.
Documentation
//! The reference advertisement `git-receive-pack` sends before the client
//! speaks — parsed for the client, rendered for the server.
//!
//! ```text
//! <old-oid> <refname>\0<capabilities>\n     the first line, and only the first
//! <old-oid> <refname>\n                     …one per ref
//! 0000
//! ```

use bstr::{BString, ByteSlice};
use gix_hash::{Kind, ObjectId};

use crate::capabilities::{self, Capabilities};
use crate::error::{Error, Result};

/// The pseudo-ref an **empty** repository advertises so that it can still carry
/// a capability list.
///
/// It is not a reference and must never become one: a client that recorded it
/// would go on to push against `capabilities^{}`.
pub const NO_REFS_PSEUDO_REF: &str = "capabilities^{}";

/// One ref as the remote advertised it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RemoteRef {
    /// Full ref name, e.g. `refs/heads/main`. Bytes, not UTF-8.
    pub name: BString,
    /// The object it currently points at.
    pub oid: ObjectId,
}

impl RemoteRef {
    /// A ref at `oid`.
    pub fn new(name: impl Into<BString>, oid: ObjectId) -> Self {
        RemoteRef {
            name: name.into(),
            oid,
        }
    }
}

/// Everything `git-receive-pack` says before the client speaks.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Advertisement {
    /// The remote's refs. **Empty for an empty repository**, which the wire
    /// signals with [`NO_REFS_PSEUDO_REF`] rather than with no lines at all,
    /// and which is therefore not the same as "the advertisement was empty".
    pub refs: Vec<RemoteRef>,
    /// What the remote can do.
    pub capabilities: Capabilities,
    /// The remote's object format, from `object-format=` when advertised and
    /// otherwise inferred from the width of the advertised oids.
    pub hash_kind: Kind,
}

impl Advertisement {
    /// The oid `name` currently points at, or the null oid. That null is the
    /// value a create must send as its `<old>`.
    pub fn oid_of(&self, name: impl AsRef<[u8]>) -> ObjectId {
        let name = name.as_ref();
        self.refs
            .iter()
            .find(|r| r.name == name)
            .map(|r| r.oid)
            .unwrap_or_else(|| ObjectId::null(self.hash_kind))
    }

    /// Was `name` advertised at all?
    pub fn has_ref(&self, name: impl AsRef<[u8]>) -> bool {
        let name = name.as_ref();
        self.refs.iter().any(|r| r.name == name)
    }
}

/// Parse an already-unframed advertisement.
///
/// Separated from the I/O so the shapes that matter — an empty repository,
/// sha256 oids, `shallow` lines, a server too old to advertise
/// `object-format` — are asserted from byte literals rather than from a live
/// server.
pub fn parse(payloads: &[Vec<u8>]) -> Result<Advertisement> {
    let mut refs: Vec<RemoteRef> = Vec::new();
    let mut caps = Capabilities::default();
    let mut seen_first = false;
    let mut oid_width: Option<usize> = None;

    for payload in payloads {
        let line = chomp(payload);
        // A `version 1` line may lead the advertisement when the client asked
        // for protocol v1. It is not a ref.
        if line == b"version 1" {
            continue;
        }
        // `ERR <msg>` may stand where the advertisement would have been: the
        // remote is refusing, in a sentence. It must be read before anything
        // splits the line on a space, or `ERR` becomes an oid and the refusal
        // becomes the ref name. See [`Error::Remote`].
        if let Some(msg) = line.strip_prefix(b"ERR ".as_slice()) {
            return Err(Error::Remote(msg.as_bstr().to_string()));
        }
        let (ref_part, caps_part) = capabilities::split(line);
        if let (false, Some(raw)) = (seen_first, caps_part) {
            caps = Capabilities::parse_bytes(raw)?;
        }
        seen_first = true;

        // `shallow <oid>` lines describe the remote's grafts, not its refs.
        if ref_part.starts_with(b"shallow ") {
            continue;
        }
        let Some(space) = ref_part.find_byte(b' ') else {
            return Err(Error::protocol(format!(
                "ref advertisement line has no space: {:?}",
                ref_part.as_bstr()
            )));
        };
        let (oid_hex, name) = (&ref_part[..space], &ref_part[space + 1..]);
        oid_width.get_or_insert(oid_hex.len());
        if name == NO_REFS_PSEUDO_REF.as_bytes() {
            continue;
        }
        let oid = ObjectId::from_hex(oid_hex).map_err(|e| {
            Error::protocol(format!(
                "bad object id {:?} for ref {:?}: {e}",
                oid_hex.as_bstr(),
                name.as_bstr()
            ))
        })?;
        refs.push(RemoteRef {
            name: name.into(),
            oid,
        });
    }

    // `object-format` is authoritative; oid width is the fallback for a server
    // too old to advertise it. Answering the wrong hash kind does not fail
    // loudly — it returns an empty response — so it is never guessed silently
    // when the remote has stated it.
    let hash_kind = match caps.value("object-format") {
        Some(name) => object_format_kind(name)?,
        None => match oid_width {
            Some(64) => Kind::Sha256,
            _ => Kind::Sha1,
        },
    };
    for r in &refs {
        if r.oid.kind() != hash_kind {
            return Err(Error::protocol(format!(
                "remote advertised {:?} as a {:?} oid but the repository is {hash_kind:?}",
                r.name.as_bstr(),
                r.oid.kind()
            )));
        }
    }

    Ok(Advertisement {
        refs,
        capabilities: caps,
        hash_kind,
    })
}

/// Render an advertisement as payload lines, **without** pkt-line framing and
/// without the terminating flush-pkt: the caller owns the framer.
///
/// This is the server half of [`parse`], and the reason the two are in one file
/// is that they are one format. A repository with no refs still emits exactly
/// one line, carrying [`NO_REFS_PSEUDO_REF`] and the null oid, because a client
/// that received nothing could not learn the hash algorithm.
pub fn lines(refs: &[RemoteRef], caps: &[String], hash_kind: Kind) -> Vec<Vec<u8>> {
    let mut out = Vec::with_capacity(refs.len().max(1));
    for (i, r) in refs.iter().enumerate() {
        let mut line = format!("{} ", r.oid.to_hex()).into_bytes();
        line.extend_from_slice(r.name.as_slice());
        if i == 0 {
            capabilities::attach(&mut line, caps);
        }
        out.push(line);
    }
    if out.is_empty() {
        let mut line = format!(
            "{} {NO_REFS_PSEUDO_REF}",
            ObjectId::null(hash_kind).to_hex()
        )
        .into_bytes();
        capabilities::attach(&mut line, caps);
        out.push(line);
    }
    out
}

/// git's name for a hash algorithm, as it appears in `object-format=`.
///
/// `gix_hash::Kind` is `#[non_exhaustive]`, so a hash kind this build has never
/// heard of is an **error** rather than a silent fallback to `sha1`. A default
/// arm here is exactly how the wrong-object-format bug comes back.
pub fn object_format_name(kind: Kind) -> Result<&'static str> {
    match kind {
        Kind::Sha1 => Ok("sha1"),
        Kind::Sha256 => Ok("sha256"),
        other => Err(Error::protocol(format!(
            "this build has no `object-format` name for {other:?}"
        ))),
    }
}

/// The inverse of [`object_format_name`].
pub fn object_format_kind(name: &str) -> Result<Kind> {
    match name {
        "sha1" => Ok(Kind::Sha1),
        "sha256" => Ok(Kind::Sha256),
        other => Err(Error::protocol(format!(
            "peer advertised an unknown object-format {other:?}"
        ))),
    }
}

/// Strip one trailing newline, the way git's own reader does.
pub(crate) fn chomp(line: &[u8]) -> &[u8] {
    line.strip_suffix(b"\n").unwrap_or(line)
}