crates/gunnar-sendpack/src/command.rs
//! 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()
}