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 command list: `<old-oid> <new-oid> <refname>`, written by the client
//! and read by the server.
//!
//! # Reference names are bytes
//!
//! `refs/heads/\xff` is a name git will create, advertise and serve, so
//! [`PushCommand::name`] is a [`BString`] and [`parse_line`] splits on **bytes**.
//!
//! Routing the line through `String::from_utf8_lossy` first — which is what a
//! `String` field forces — replaces every invalid byte with U+FFFD *before* the
//! name is validated. A non-UTF-8 name is then accepted and stored under a name
//! the client never asked for, and any two names differing only in their invalid
//! bytes collapse onto the same reference, so a push to one silently overwrites
//! the other. That was a real bug in gunnar's server. The lossy rendering
//! survives only in the error messages here, where it is read by a human and
//! not by a store.

use std::collections::HashSet;

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

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

/// One line of the command list: move `name` from `old` to `new`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PushCommand {
    /// Full ref name on the remote.
    pub name: BString,
    /// What the client believes the remote currently has. The remote compares
    /// it and rejects on mismatch; this field **is** the compare-and-swap.
    pub old: ObjectId,
    /// What it should become. Null means delete.
    pub new: ObjectId,
}

impl PushCommand {
    /// Move `name` from `old` to `new`.
    pub fn update(name: impl Into<BString>, old: ObjectId, new: ObjectId) -> Self {
        PushCommand {
            name: name.into(),
            old,
            new,
        }
    }

    /// Create `name` at `new`, with the null oid as `<old>`.
    pub fn create(name: impl Into<BString>, new: ObjectId) -> Self {
        PushCommand {
            name: name.into(),
            old: ObjectId::null(new.kind()),
            new,
        }
    }

    /// Delete `name`, which the remote currently has at `old`.
    pub fn delete(name: impl Into<BString>, old: ObjectId) -> Self {
        PushCommand {
            name: name.into(),
            new: ObjectId::null(old.kind()),
            old,
        }
    }

    /// True when this removes the ref.
    pub fn is_delete(&self) -> bool {
        self.new.is_null()
    }

    /// True when the remote is not expected to have this ref yet.
    pub fn is_create(&self) -> bool {
        self.old.is_null()
    }

    /// The wire form, without the capability suffix and without a trailing
    /// newline.
    pub fn line(&self) -> Vec<u8> {
        let mut out = format!("{} {} ", self.old.to_hex(), self.new.to_hex()).into_bytes();
        out.extend_from_slice(self.name.as_slice());
        out
    }
}

/// A `shallow <oid>` line, which a shallow client sends **before** its commands
/// and which is not a command.
pub const SHALLOW_PREFIX: &[u8] = b"shallow ";

/// Parse one command line, having already had its capability suffix removed by
/// [`capabilities::split`].
///
/// `hash_kind` is the repository's, and an oid of the wrong width is refused
/// here rather than downstream: a 40-hex id in a sha256 repository names
/// nothing, and accepting it produces a rejection whose message blames the
/// reference instead of the object format.
pub fn parse_line(line: &[u8], hash_kind: Kind) -> Result<PushCommand> {
    let mut parts = line.splitn(3, |&b| b == b' ');
    let (Some(old), Some(new), Some(name)) = (parts.next(), parts.next(), parts.next()) else {
        return Err(Error::protocol(format!(
            "{:?} is not an `<old> <new> <ref>` command",
            line.as_bstr()
        )));
    };
    let parse = |hex: &[u8]| -> Result<ObjectId> {
        let id = ObjectId::from_hex(hex).map_err(|err| {
            Error::protocol(format!("{:?} is not an object id: {err}", hex.as_bstr()))
        })?;
        if id.kind() != hash_kind {
            return Err(Error::ObjectFormat {
                ours: crate::advertisement::object_format_name(hash_kind).unwrap_or("unsupported"),
                theirs: crate::advertisement::object_format_name(id.kind())
                    .unwrap_or("unsupported"),
            });
        }
        Ok(id)
    };
    let (old, new) = (parse(old)?, parse(new)?);
    if old.is_null() && new.is_null() {
        return Err(Error::protocol(format!(
            "{:?} has a null old AND a null new oid, which is neither a create, \
             an update nor a delete",
            name.as_bstr()
        )));
    }
    Ok(PushCommand {
        name: name.into(),
        old,
        new,
    })
}

/// Render the command list as payload lines, **without** pkt-line framing,
/// without the terminating flush-pkt and **without the trailing newline**: the
/// caller owns the framer.
///
/// The capabilities ride on the **first line only**, after a NUL.
///
/// The newline is the framer's, not this function's, and every `lines` in this
/// crate agrees on that. git frames these as *text* pkt-lines, which is
/// `<length><payload>\n`; if the payload carried its own newline as well, one
/// end of this crate would emit two and the other would chomp one, and the
/// difference is invisible until a peer that does not chomp reads a reference
/// name with a newline glued to it.
pub fn lines(commands: &[PushCommand], caps: &[String]) -> Result<Vec<Vec<u8>>> {
    if commands.is_empty() {
        return Err(Error::invalid(
            "a push with no commands has nothing to say; send nothing instead",
        ));
    }
    let mut seen: HashSet<&[u8]> = HashSet::new();
    for c in commands {
        if !seen.insert(c.name.as_slice()) {
            return Err(Error::invalid(format!(
                "the command list names {:?} twice; the remote would apply one of \
                 them and the client could not say which",
                c.name.as_bstr()
            )));
        }
        if c.is_delete() && c.is_create() {
            return Err(Error::invalid(format!(
                "{:?} has a null old AND a null new oid, which is neither a create, \
                 an update nor a delete",
                c.name.as_bstr()
            )));
        }
        if c.name.contains(&b'\n') || c.name.contains(&0) {
            return Err(Error::invalid(format!(
                "the reference name {:?} contains a newline or a NUL, either of \
                 which would be read as the end of the line",
                c.name.as_bstr()
            )));
        }
    }

    let mut out = Vec::with_capacity(commands.len());
    for (i, c) in commands.iter().enumerate() {
        let mut line = c.line();
        if i == 0 {
            capabilities::attach(&mut line, caps);
        }
        out.push(line);
    }
    Ok(out)
}

/// Render the push-options section as payload lines.
pub fn push_option_lines(options: &[String]) -> Result<Vec<Vec<u8>>> {
    options
        .iter()
        .map(|opt| {
            if opt.contains('\n') {
                return Err(Error::invalid(format!(
                    "push option {opt:?} contains a newline, which the wire cannot carry"
                )));
            }
            Ok(opt.as_bytes().to_vec())
        })
        .collect()
}