crates/gunnar-pktline/src/encode.rs
//! Encoding: payloads in, framed pkt-lines out.
//!
//! Everything here writes into a `std::io::Write`. `Vec<u8>` implements it, so
//! buffering a whole response costs nothing extra, while a socket or an SSH
//! channel can be written to directly. That matters for the packfile, where
//! buffering the entire response before sending it would mean holding a
//! multi-gigabyte clone in memory.
use crate::{Error, Packet, Result, MAX_PAYLOAD};
use std::io::Write;
/// The 4 ASCII hex digits git wants, lowercase, zero-padded.
///
/// Written by hand rather than through `format!` because this runs once per
/// packet (thousands of times per pack served) and because the fixed width is
/// the invariant: `{:x}` of a small number is *not* a valid pkt-line header, and
/// a missing zero pad desyncs the stream.
fn hex4(n: usize) -> [u8; 4] {
const DIGITS: &[u8; 16] = b"0123456789abcdef";
debug_assert!(n <= 0xffff, "pkt-line length {n} does not fit 4 hex digits");
[
DIGITS[(n >> 12) & 0xf],
DIGITS[(n >> 8) & 0xf],
DIGITS[(n >> 4) & 0xf],
DIGITS[n & 0xf],
]
}
/// Write one data packet: the 4-digit length **including the header** followed
/// by `data` verbatim.
///
/// `write_data(out, b"hi")` emits exactly `b"0006hi"`. The payload is not
/// touched. No newline is added and nothing is trimmed, because pkt-lines carry
/// binary as readily as text.
///
/// An empty `data` is legal and emits `b"0004"`, which is a data packet with no
/// payload and is *not* a flush-pkt. If a flush is what is meant, call
/// [`write_flush`].
pub fn write_data<W: Write>(out: &mut W, data: &[u8]) -> Result<()> {
if data.len() > MAX_PAYLOAD {
return Err(Error::TooLong(data.len()));
}
out.write_all(&hex4(data.len() + 4))?;
out.write_all(data)?;
Ok(())
}
/// Write one text packet, appending `\n` if `text` does not already end in one.
///
/// git's line-oriented commands (`want <oid>`, `command=fetch`, ref
/// advertisement lines, status reports) are newline-terminated, and a peer that
/// splits on the newline sees two commands run together when it is missing. The
/// newline is only added when absent, so a caller that already terminated its
/// line does not get a blank one appended, and [`Packet::text`] strips exactly
/// one, making the pair round-trip.
///
/// [`Packet::text`]: crate::Packet::text
pub fn write_text<W: Write>(out: &mut W, text: &[u8]) -> Result<()> {
if text.ends_with(b"\n") {
return write_data(out, text);
}
// +1 for the newline this is about to add; the check must cover the bytes
// actually written, not the bytes handed in.
if text.len() + 1 > MAX_PAYLOAD {
return Err(Error::TooLong(text.len() + 1));
}
out.write_all(&hex4(text.len() + 5))?;
out.write_all(text)?;
out.write_all(b"\n")?;
Ok(())
}
/// Write a flush-pkt: exactly `b"0000"`. Ends a section or a response.
pub fn write_flush<W: Write>(out: &mut W) -> Result<()> {
out.write_all(b"0000")?;
Ok(())
}
/// Write a delim-pkt: exactly `b"0001"`. Protocol v2, between a command's
/// arguments and its payload.
pub fn write_delim<W: Write>(out: &mut W) -> Result<()> {
out.write_all(b"0001")?;
Ok(())
}
/// Write a response-end-pkt: exactly `b"0002"`. Protocol v2 stateless rpc.
pub fn write_response_end<W: Write>(out: &mut W) -> Result<()> {
out.write_all(b"0002")?;
Ok(())
}
/// Write any [`Packet`], control or data. The exact inverse of
/// [`crate::decode`].
pub fn write_packet<W: Write>(out: &mut W, pkt: &Packet) -> Result<()> {
match pkt {
Packet::Data(d) => write_data(out, d),
Packet::Flush => write_flush(out),
Packet::Delim => write_delim(out),
Packet::ResponseEnd => write_response_end(out),
}
}
/// The most data one sideband line can carry: [`MAX_PAYLOAD`] minus the band
/// byte, which is part of the payload and therefore part of the budget.
pub const SIDEBAND_CHUNK: usize = MAX_PAYLOAD - 1;
/// Write `data` as one or more sideband lines on `band`, splitting at
/// [`SIDEBAND_CHUNK`].
///
/// Each line is `<len><band><chunk>`: the band number is the **first byte of the
/// payload**, so a full line is `1 + SIDEBAND_CHUNK == MAX_PAYLOAD` bytes of
/// payload and `MAX_LINE` on the wire. Forgetting that the band byte comes out
/// of the payload budget produces lines one byte over the limit, which every
/// conformant peer rejects, and only for payloads that happen to land on the
/// boundary, so small-input tests never see it.
///
/// Splitting is the caller's problem otherwise: a packfile is arbitrarily large
/// and the protocol has no long-line form. Chunking here, once, is the reason no
/// caller in gunnar loops over `MAX_PAYLOAD` itself.
///
/// **Empty `data` writes nothing at all**, not a zero-length line. A bare band
/// byte would be a legal-looking packet carrying no data, which a client reading
/// band 1 would append to the packfile as zero bytes but which still costs a
/// frame; worse, on band 3 it would be an error packet announcing an empty
/// error. "Nothing to send" must produce nothing.
///
/// [`MAX_PAYLOAD`]: crate::MAX_PAYLOAD
/// [`MAX_LINE`]: crate::MAX_LINE
pub fn write_sideband<W: Write>(out: &mut W, band: u8, data: &[u8]) -> Result<()> {
for chunk in data.chunks(SIDEBAND_CHUNK) {
out.write_all(&hex4(chunk.len() + 5))?;
out.write_all(&[band])?;
out.write_all(chunk)?;
}
Ok(())
}