crates/gunnar-sendpack/src/capabilities.rs
//! The capability list, which both ends parse and both ends write.
//!
//! In protocol v0 — the only protocol push has — capabilities ride on the
//! **first line** of the reference advertisement and of the command list, after
//! a NUL byte. Every later line carries none. That asymmetry is the single
//! most-repeated bug in the format, and it is why [`split`] exists as one
//! function rather than as a `line.split(0)` written at four call sites.
use crate::error::{Error, Result};
/// A space-separated capability list, kept as raw strings.
///
/// Not a bitfield: `agent=…` and `object-format=…` carry values, unknown
/// capabilities must survive round-tripping for diagnostics, and the set grows
/// with every git release. A server that dropped what it did not recognise
/// would report a peer's capabilities as smaller than they were.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Capabilities(Vec<String>);
impl Capabilities {
/// Parse a space-separated capability list.
///
/// Empty items are dropped. git's own `receive-pack` client emits
/// `…\0 report-status-v2 …` with a **leading** space, which a naive
/// `split(' ')` turns into a phantom capability with an empty name.
pub fn parse(raw: &str) -> Self {
Capabilities(
raw.split(' ')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_owned)
.collect(),
)
}
/// Parse from bytes, refusing a list that is not UTF-8.
///
/// Reference *names* are bytes (see [`crate::command`]); capability names
/// are not — every one git defines is ASCII, and a non-ASCII one would be a
/// peer this grammar has no way to satisfy.
pub fn parse_bytes(raw: &[u8]) -> Result<Self> {
let text = std::str::from_utf8(raw)
.map_err(|_| Error::protocol("the capability list is not UTF-8"))?;
Ok(Self::parse(text))
}
/// Build from an ordered list.
pub fn from_items<S: Into<String>>(items: impl IntoIterator<Item = S>) -> Self {
Capabilities(items.into_iter().map(Into::into).collect())
}
/// Is this capability present, with or without a value?
pub fn has(&self, name: &str) -> bool {
self.0.iter().any(|c| {
c == name || (c.starts_with(name) && c.as_bytes().get(name.len()) == Some(&b'='))
})
}
/// The value of `name=value`, or `None` when absent or valueless.
pub fn value(&self, name: &str) -> Option<&str> {
self.0.iter().find_map(|c| {
let rest = c.strip_prefix(name)?;
rest.strip_prefix('=')
})
}
/// Every capability, in the order it appeared.
pub fn all(&self) -> &[String] {
&self.0
}
/// The wire form: the items joined by single spaces.
pub fn render(&self) -> String {
self.0.join(" ")
}
/// Is the list empty?
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
}
/// Split a first line into its content and its capability list.
///
/// Returns `(content, Some(capabilities))` when a NUL is present and
/// `(line, None)` when it is not. **A line with a NUL and nothing after it has
/// an empty capability list, not an absent one**, which is why the second
/// element is `Option<&[u8]>` rather than a slice that could be empty for two
/// different reasons.
pub fn split(line: &[u8]) -> (&[u8], Option<&[u8]>) {
match line.iter().position(|&b| b == 0) {
Some(i) => (&line[..i], Some(&line[i + 1..])),
None => (line, None),
}
}
/// Append `capabilities` to `line` the way the wire wants it: a NUL, then the
/// list. A no-op for an empty list, because `git` reads a trailing NUL with
/// nothing after it as a capability list it then fails to parse.
pub fn attach(line: &mut Vec<u8>, capabilities: &[String]) {
if capabilities.is_empty() {
return;
}
line.push(0);
line.extend_from_slice(capabilities.join(" ").as_bytes());
}