Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

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>;