crates/gunnar-pktline/src/lib.rs
//! **pkt-line**: the framing layer of git's smart protocol, encoder and
//! decoder, with **zero dependencies**.
//!
//! Every byte gunnar puts on or takes off a git connection passes through here:
//! the ref advertisement, protocol-v2 command sections, the `have`/`want`
//! negotiation, the sideband-multiplexed packfile, and receive-pack's command
//! list and status report. It is the leaf of the dependency graph and the one
//! crate both halves of gunnar (client and no-OS server) share.
//!
//! # The format, exactly
//!
//! A pkt-line is **4 ASCII hex digits of length, then that many bytes minus 4**:
//!
//! ```text
//! 0006hi → Data(b"hi") — 0x0006 = 6 bytes total, 2 of payload
//! 0000 → Flush — end of a section / of the stream
//! 0001 → Delim — protocol v2 section delimiter
//! 0002 → ResponseEnd — protocol v2 stateless-rpc response end
//! 0003 → RESERVED. an error, never a packet.
//! ```
//!
//! **The length counts its own four header bytes.** This is the single most
//! commonly mis-implemented detail of the format: an off-by-four here desyncs
//! the stream at the very first packet and the resulting corruption looks like a
//! network fault, not a bug. Hence [`decode`] returns the number of bytes
//! consumed *including* the header, and the encoders write `payload.len() + 4`.
//!
//! # Sideband
//!
//! During `fetch`, the packfile is multiplexed with progress and error text: the
//! **first payload byte is a band number** ([`BAND_PACK`], [`BAND_PROGRESS`],
//! [`BAND_ERROR`]) and the rest is that band's data. Since the band byte lives
//! *inside* the payload, a sideband line carries at most `MAX_PAYLOAD - 1` bytes
//! of actual data. See [`write_sideband`], which is where that subtraction is
//! made once so no caller has to remember it.
//!
//! # Why an incremental [`Decoder`] and not just [`decode`]
//!
//! A socket hands over arbitrary chunks. A packet can and does arrive split
//! across two reads, including split *inside its own 4-byte header*. The
//! decoder therefore has exactly one rule that matters: **a short buffer is
//! [`Decoded::Incomplete`], never an error and never a truncated packet.** A
//! decoder that reports a malformed packet when it has merely not been fed
//! enough bytes turns a normal read boundary into a dropped connection, and the
//! failure is timing-dependent, so it will not reproduce.
//!
//! # Why [`Decoder::into_remainder`] exists
//!
//! `git-receive-pack` sends its command list as pkt-lines, then a flush-pkt, and
//! then **a raw packfile that is not pkt-line framed at all**. The same is true
//! of a non-sideband fetch response. Whoever reads the framed part has almost
//! certainly already pulled some of those raw bytes into its buffer, and those
//! bytes are the head of a packfile whose trailing hash covers every one of
//! them. Losing or duplicating a single byte there produces a pack that fails
//! its checksum, reported by git as a corrupt push, with nothing pointing at
//! the framing layer. [`Decoder::into_remainder`] hands back everything past the
//! last packet already returned, byte-exact, so the pack reader can pick up
//! precisely where the framer stopped.
#![forbid(unsafe_code)]
#![deny(missing_docs)]
mod decode;
mod encode;
mod read;
pub use decode::{decode, Decoded, Decoder};
pub use encode::{
write_data, write_delim, write_flush, write_packet, write_response_end, write_sideband,
write_text, SIDEBAND_CHUNK,
};
pub use read::{expect_packet, read_packet};
/// The largest payload a single pkt-line may carry: `65516` bytes.
///
/// git's `LARGE_PACKET_MAX` is 65520 bytes for the whole line, and the 4-byte
/// length header is part of that budget, so the payload ceiling is `65520 - 4`.
/// The largest legal length header is therefore `fff0`; `fff1`..=`ffff` are not
/// "big packets", they are malformed input and are rejected as [`Error::TooLong`].
pub const MAX_PAYLOAD: usize = 65516;
/// The largest legal on-the-wire line: [`MAX_PAYLOAD`] plus its 4-byte header.
pub const MAX_LINE: usize = MAX_PAYLOAD + 4;
/// Sideband 1: packfile data. Belongs in the object stream, verbatim.
pub const BAND_PACK: u8 = 1;
/// Sideband 2 carries human-readable progress. Goes to the client's stderr; it
/// must never be mistaken for pack bytes.
pub const BAND_PROGRESS: u8 = 2;
/// Sideband 3 is a fatal error message. The client aborts on it.
pub const BAND_ERROR: u8 = 3;
/// One framed unit of the smart protocol.
///
/// The three zero-payload forms are *not* data packets with empty payloads.
/// `0000` and `0004` are different bytes with different meanings: a flush ends
/// a section, an empty data packet is a legal (if unusual) line inside one.
/// Collapsing them is how a v2 section boundary gets lost.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Packet {
/// A normal line. The payload as it appeared on the wire, header stripped,
/// **including** any trailing newline. See [`Packet::text`].
Data(Vec<u8>),
/// `0000`, the flush-pkt. Ends a section, a response, or the whole stream.
Flush,
/// `0001`, the delim-pkt. Separates the argument list from the payload
/// inside a protocol-v2 command.
Delim,
/// `0002` is the response-end-pkt, ending a stateless-rpc response in v2.
ResponseEnd,
}
impl Packet {
/// The raw payload of a [`Packet::Data`], exactly as framed; `None` for the
/// three control packets.
pub fn payload(&self) -> Option<&[u8]> {
match self {
Packet::Data(d) => Some(d),
_ => None,
}
}
/// The payload with **one** trailing `\n` removed, if there is one.
///
/// git's text lines are newline-terminated by convention but the newline is
/// not part of the value: `want <oid>\n` names the oid, not `oid\n`. Exactly
/// one newline is stripped, never more: a payload of `b"abc\n\n"` yields
/// `b"abc\n"`, because the second newline is content. Greedy trimming here
/// would silently alter the payload of any command whose last argument
/// legitimately ends in a blank line.
pub fn text(&self) -> Option<&[u8]> {
self.payload().map(|p| p.strip_suffix(b"\n").unwrap_or(p))
}
/// [`Packet::text`] as UTF-8, or `None` if this is a control packet or the
/// payload is not valid UTF-8.
///
/// Binary is legal in a pkt-line (this is how packfiles travel), so a
/// non-UTF-8 payload is not an error of the framing layer and must not be
/// reported as one; it is simply not text.
pub fn text_str(&self) -> Option<&str> {
self.text().and_then(|t| std::str::from_utf8(t).ok())
}
/// True for [`Packet::Flush`].
pub fn is_flush(&self) -> bool {
matches!(self, Packet::Flush)
}
}
/// Everything that can go wrong framing or unframing pkt-lines.
///
/// Deliberately a plain enum with no `thiserror`, no `anyhow`: this crate must
/// link into an appliance image that carries nothing under it (§0 of the design).
#[derive(Debug)]
pub enum Error {
/// The 4 header bytes are not 4 ASCII hex digits. Carries them verbatim so
/// the diagnostic can show what actually arrived, usually the first bytes
/// of an SSH banner, an error message, or a stream that desynced earlier.
BadLength([u8; 4]),
/// `0003`. Reserved by the protocol and assigned no meaning, so it can only
/// be a bug or a corrupted stream. Never silently treat it as a packet.
ReservedLength,
/// A declared payload length above [`MAX_PAYLOAD`]. The value is the
/// declared **payload** size (the header's number minus its own 4 bytes), so
/// it compares directly against [`MAX_PAYLOAD`].
///
/// Rejected the moment the *header* is read, never after waiting for the
/// bytes: a corrupt length of `ffff` on a stream that will never carry
/// another byte would otherwise hang the connection until a timeout, which
/// presents as a network problem rather than as the malformed frame it is.
TooLong(usize),
/// The underlying writer failed. Encode-side only; [`decode`] never
/// produces it.
Io(std::io::Error),
}
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Error::BadLength(b) => {
write!(
f,
"pkt-line length is not 4 hex digits: {:?}",
EscapeAscii(b)
)
}
Error::ReservedLength => f.write_str("pkt-line length 0003 is reserved"),
Error::TooLong(n) => write!(
f,
"pkt-line payload of {n} bytes exceeds the maximum of {MAX_PAYLOAD}"
),
Error::Io(e) => write!(f, "pkt-line write failed: {e}"),
}
}
}
impl std::error::Error for Error {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Error::Io(e) => Some(e),
_ => None,
}
}
}
impl From<std::io::Error> for Error {
fn from(e: std::io::Error) -> Self {
Error::Io(e)
}
}
/// Renders bad header bytes readably. They are arbitrary (the whole reason the
/// header was rejected), so they must never be assumed printable.
struct EscapeAscii<'a>(&'a [u8]);
impl std::fmt::Debug for EscapeAscii<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "\"")?;
for &b in self.0 {
for c in std::ascii::escape_default(b) {
write!(f, "{}", c as char)?;
}
}
write!(f, "\"")
}
}
/// The crate's result type.
pub type Result<T> = std::result::Result<T, Error>;