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

nordisk/gunnar

crates/gunnar-lfs/src/wire.rs

//! The framing, exactly as the git-lfs client speaks it.
//!
//! Every shape here was read off the real client rather than off the proposal
//! document, because two of them differ from what the grammar implies and both
//! differences break interop:
//!
//! * **A `quit` response must NOT carry a delim-pkt.** `quit` is the one command
//!   the client reads with `PktlineConnection.ReadStatus`, which accepts a
//!   status line and then a flush and calls *anything else*, a delim included,
//!   `unexpected data`. Every other command is read with `ReadStatusWithLines`
//!   or `ReadStatusWithData`, which do accept one. See
//!   [`write_status`] against [`write_status_lines`].
//! * **A `size=` argument is mandatory on a `get-object` success.** The client
//!   errors with `no size argument seen` without it, before reading a byte of
//!   the object.
//!
//! Sources, both read on 2026-08-03: `git-lfs/ssh/protocol.go` (client) and
//! `charmbracelet/git-lfs-transfer/transfer/pktline.go` (the reference server).

use std::io::{BufRead, Write};

use gunnar_pktline::Packet;

use crate::error::{Error, Result};
use crate::limits::CHUNK_BYTES;

/// How a section ended.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SectionEnd {
    /// `0001`: more of this command follows.
    Delim,
    /// `0000`: the command is complete.
    Flush,
}

impl SectionEnd {
    /// The packet's name, for a diagnostic the client can act on.
    pub(crate) fn name(self) -> &'static str {
        match self {
            SectionEnd::Delim => "delim-pkt",
            SectionEnd::Flush => "flush-pkt",
        }
    }
}

/// Read a command's `key=value` argument lines up to the first delim-pkt or
/// flush-pkt, and say which one arrived.
///
/// Returning the terminator rather than assuming it is what lets `put-object`
/// tell "here comes the object" (delim) from "there is no object" (flush)
/// instead of blocking on bytes that are not coming.
pub(crate) fn read_args(
    input: &mut dyn BufRead,
    max_args: usize,
    what: &'static str,
) -> Result<(Vec<String>, SectionEnd)> {
    let mut args = Vec::new();
    loop {
        match gunnar_pktline::expect_packet(input, what)? {
            Packet::Flush => return Ok((args, SectionEnd::Flush)),
            Packet::Delim => return Ok((args, SectionEnd::Delim)),
            Packet::Data(data) => {
                if args.len() >= max_args {
                    // Refused as it happens, not after collecting them all: the
                    // point of a cap is that the collection never grows past it.
                    return Err(Error::BadRequest(format!(
                        "more than {max_args} arguments on one command"
                    )));
                }
                args.push(text_of(&data, what)?);
            }
            Packet::ResponseEnd => {
                return Err(Error::Framing(format!(
                    "a response-end-pkt appeared while reading {what}; \
                     it belongs to git protocol v2 and has no meaning here"
                )))
            }
        }
    }
}

/// The bytes of text one capped section may accumulate, per line it is allowed
/// to carry.
///
/// GUN-008. `read_lines` capped the COUNT and nothing else, and a pkt-line
/// carries up to `gunnar_pktline::MAX_PAYLOAD` — 65 516 bytes. With the default
/// `max_batch_objects` of 10 000 that is 10 000 × 65 516 ≈ **628 MiB of
/// `String`**, allocated by one `batch` request from any client that may talk
/// to the repository, before a single line is looked at. The module doc above
/// already states the discipline — "a test in this tree OOM'd a 499 GB machine
/// by allocating what a header claimed" — and this was the one reader that did
/// not follow it.
///
/// 256 bytes a line, against a real batch line of `<64 hex> <decimal size>` ≈
/// 85: three times what the format needs, and the ceiling it produces at the
/// default is 2.5 MiB rather than 628. It is per allowed line rather than a
/// flat number so that an operator who raises `max_batch_objects` raises the
/// byte budget with it, and one who lowers it lowers both.
///
/// `read_args` is the same shape and is left alone: `max_args` is 32, so its
/// worst case is 2 MiB — bounded, and small enough that adding a second
/// ceiling would be ceremony rather than a fix.
pub(crate) const MAX_LINE_BYTES: usize = 256;

/// Read text lines up to a flush-pkt, capped in COUNT and in BYTES.
pub(crate) fn read_lines(
    input: &mut dyn BufRead,
    max_lines: usize,
    what: &'static str,
) -> Result<Vec<String>> {
    let mut lines = Vec::new();
    // Derived rather than passed, so no call site can forget it. See
    // [`MAX_LINE_BYTES`].
    let max_bytes = max_lines.saturating_mul(MAX_LINE_BYTES);
    let mut bytes = 0usize;
    loop {
        match gunnar_pktline::expect_packet(input, what)? {
            Packet::Flush => return Ok(lines),
            Packet::Data(data) => {
                if lines.len() >= max_lines {
                    return Err(Error::BadRequest(format!(
                        "more than {max_lines} lines in {what}"
                    )));
                }
                // Refused as it happens, like the count above: the point of a
                // ceiling is that the collection never grows past it, not that
                // the server reports how far past it already went.
                bytes = bytes.saturating_add(data.len());
                if bytes > max_bytes {
                    return Err(Error::BadRequest(format!(
                        "more than {max_bytes} bytes of text in {what}"
                    )));
                }
                lines.push(text_of(&data, what)?);
            }
            other => {
                return Err(Error::Framing(format!(
                    "unexpected {other:?} while reading {what}"
                )))
            }
        }
    }
}

fn text_of(data: &[u8], what: &'static str) -> Result<String> {
    let trimmed = data.strip_suffix(b"\n").unwrap_or(data);
    std::str::from_utf8(trimmed)
        .map(str::to_owned)
        .map_err(|_| Error::BadRequest(format!("a non-UTF-8 line in {what}")))
}

/// The value of `key=…` among `args`, or `None`.
///
/// A bare `key` with no `=` yields `Some("")`, matching the spec's
/// `key=[data]` production where the data may be empty.
pub(crate) fn arg<'a>(args: &'a [String], key: &str) -> Option<&'a str> {
    args.iter().find_map(|entry| {
        let rest = entry.strip_prefix(key)?;
        match rest.strip_prefix('=') {
            Some(value) => Some(value),
            None if rest.is_empty() => Some(""),
            None => None,
        }
    })
}

// ── writing ────────────────────────────────────────────────────────────────

/// The capability advertisement the server opens with.
///
/// **`locking` is deliberately absent.** The specification makes it optional:
/// *"if the server supports locking, the `locking` capability should be
/// advertised, and the client may then use the `lock`, `unlock`, and
/// `list-lock` commands"*. gunnar has no lock table. The three verbs are
/// answered `501`, which git-lfs's `lockVerifier` turns into
/// `NewNotImplementedError` and then `disableFor(url)`: locking switches itself
/// off silently and a push proceeds. Answering `405` instead makes the client
/// print *"Remote does not support the Git LFS locking API"* on every push, and
/// answering `200` with an empty list would be a lie: it says *no file is
/// locked* about a server that does not know.
pub const CAPABILITIES: &[&str] = &["version=1"];

/// The protocol version this server speaks.
///
/// **1, and the git-lfs specification has only ever defined 1.** The LFS design's note
/// reading "v2, not v1" is about *gunnar's* release phase (the "Not in v1" list), not
/// about the wire: `version=1` is the capability the client scans for and
/// `version 1` is the command it sends. A server advertising `version=2` is one
/// that git-lfs refuses with *"missing version=1"*.
pub const PROTOCOL_VERSION: &str = "1";

pub(crate) fn write_capabilities(out: &mut dyn Write) -> Result<()> {
    for capability in CAPABILITIES {
        write_text(out, capability)?;
    }
    write_flush(out)
}

/// `status <code>`, then arguments, then a flush. **No delim.**
///
/// This is the shape `quit` requires; see the module docs.
pub(crate) fn write_status(out: &mut dyn Write, code: u16, args: &[String]) -> Result<()> {
    write_text(out, &format!("status {code}"))?;
    for arg in args {
        write_text(out, arg)?;
    }
    write_flush(out)
}

/// `status <code>`, arguments, a delim, then text lines, then a flush.
pub(crate) fn write_status_lines(
    out: &mut dyn Write,
    code: u16,
    args: &[String],
    lines: &[String],
) -> Result<()> {
    write_text(out, &format!("status {code}"))?;
    for arg in args {
        write_text(out, arg)?;
    }
    write_delim(out)?;
    for line in lines {
        write_text(out, line)?;
    }
    write_flush(out)
}

/// An error response: the status, a delim, one message line, a flush.
///
/// The message is what the client prints, so it is the refusal in the operator's
/// words and never a store path, a principal or a stack.
pub(crate) fn write_error(out: &mut dyn Write, code: u16, message: &str) -> Result<()> {
    write_status_lines(out, code, &[], &[sanitise(message)])
}

/// One line of client-visible text, with anything that could forge a pkt-line
/// boundary or a terminal escape removed.
fn sanitise(message: &str) -> String {
    message
        .chars()
        .map(|c| if c.is_control() { ' ' } else { c })
        .take(512)
        .collect()
}

pub(crate) fn write_text(out: &mut dyn Write, line: &str) -> Result<()> {
    // The reborrow needs a named binding: `&mut *out` alone makes inference
    // pick `W = dyn Write`, which is unsized.
    let mut sink: &mut dyn Write = out;
    gunnar_pktline::write_text(&mut sink, line.as_bytes())?;
    Ok(())
}

pub(crate) fn write_flush(out: &mut dyn Write) -> Result<()> {
    let mut sink: &mut dyn Write = out;
    gunnar_pktline::write_flush(&mut sink)?;
    Ok(())
}

pub(crate) fn write_delim(out: &mut dyn Write) -> Result<()> {
    let mut sink: &mut dyn Write = out;
    gunnar_pktline::write_delim(&mut sink)?;
    Ok(())
}

/// A [`Write`] that frames whatever is written to it as data pkt-lines.
///
/// **This is the object's outbound path, and it is why a `get-object` of a
/// 40 GiB file costs [`CHUNK_BYTES`] of memory.** The buffer is allocated once,
/// filled, emitted as one pkt-line, and reused. Nothing anywhere on this path
/// holds a second copy, and nothing sizes an allocation from the object.
pub(crate) struct DataFramer<'a> {
    out: &'a mut dyn Write,
    buf: Vec<u8>,
    framed: u64,
}

impl<'a> DataFramer<'a> {
    pub(crate) fn new(out: &'a mut dyn Write) -> Self {
        DataFramer {
            out,
            buf: Vec::with_capacity(CHUNK_BYTES),
            framed: 0,
        }
    }

    /// Emit whatever is buffered. Does **not** write the terminating flush.
    /// That is the caller's, because the caller knows whether an error cut the
    /// stream short.
    pub(crate) fn finish(mut self) -> Result<u64> {
        self.emit()?;
        Ok(self.framed)
    }

    fn emit(&mut self) -> Result<()> {
        if self.buf.is_empty() {
            return Ok(());
        }
        let mut sink: &mut dyn Write = self.out;
        gunnar_pktline::write_data(&mut sink, &self.buf)?;
        self.framed += self.buf.len() as u64;
        self.buf.clear();
        Ok(())
    }
}

impl Write for DataFramer<'_> {
    fn write(&mut self, data: &[u8]) -> std::io::Result<usize> {
        if data.is_empty() {
            return Ok(0);
        }
        let room = CHUNK_BYTES - self.buf.len();
        let take = room.min(data.len());
        self.buf.extend_from_slice(&data[..take]);
        if self.buf.len() == CHUNK_BYTES {
            self.emit().map_err(as_io)?;
        }
        Ok(take)
    }

    fn flush(&mut self) -> std::io::Result<()> {
        self.emit().map_err(as_io)?;
        self.out.flush()
    }
}

fn as_io(err: Error) -> std::io::Error {
    match err {
        Error::Io(io) => io,
        other => std::io::Error::other(other.to_string()),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn quit_gets_no_delim_and_everything_else_does() {
        // The interop detail. `PktlineConnection.ReadStatus`, the reader
        // `quit` uses, treats a delim as `unexpected data`.
        let mut out = Vec::new();
        write_status(&mut out, 200, &[]).unwrap();
        assert_eq!(out, b"000fstatus 200\n0000");

        let mut out = Vec::new();
        write_status_lines(&mut out, 200, &[], &[]).unwrap();
        assert_eq!(out, b"000fstatus 200\n00010000");
    }

    #[test]
    fn an_error_carries_its_message_where_the_client_reads_it() {
        let mut out = Vec::new();
        write_error(&mut out, 404, "object not found").unwrap();
        let text = String::from_utf8_lossy(&out).to_string();
        assert!(text.starts_with("000fstatus 404\n0001"), "{text}");
        assert!(text.contains("object not found"), "{text}");
    }

    #[test]
    fn a_control_byte_in_a_message_cannot_forge_a_frame() {
        let mut out = Vec::new();
        write_error(&mut out, 400, "bad\n0000stat\u{1b}[2Jus 200").unwrap();
        let text = String::from_utf8_lossy(&out).to_string();
        // Exactly one status line, and no bare newline smuggled inside the
        // message payload; the encoder appends the only newline there is.
        assert_eq!(text.matches("status 4").count(), 1);
        assert_eq!(text.matches('\n').count(), 2);
        assert!(!text.contains('\u{1b}'));
    }

    /// **GUN-008.** The section is bounded in bytes, not only in lines.
    ///
    /// Mutation-proven 2026-09-11: drop the `bytes > max_bytes` arm and the
    /// first case below returns 100 lines totalling 6.5 MB — at the shipped
    /// `max_batch_objects` of 10 000 that is the 628 MiB the review measured,
    /// from one `batch` request, before anything looks at a line.
    #[test]
    fn a_capped_section_is_capped_in_bytes_and_not_only_in_lines() {
        // One hundred maximal pkt-lines: legal framing, legal count, and
        // 6 551 600 bytes of text against a ceiling of 100 × 256.
        let line = vec![b'x'; gunnar_pktline::MAX_PAYLOAD];
        let mut wire = Vec::new();
        for _ in 0..100 {
            gunnar_pktline::write_data(&mut wire, &line).expect("frame");
        }
        gunnar_pktline::write_flush(&mut wire).expect("flush");
        // Mapped to its size before `expect_err`: the failure message of a
        // reader that DID return would otherwise be 6.5 MB of `x`.
        let err = read_lines(&mut wire.as_slice(), 100, "the batch objects")
            .map(|lines| lines.iter().map(String::len).sum::<usize>())
            .expect_err("a section of maximal lines must be refused");
        assert!(
            matches!(err, Error::BadRequest(_)),
            "a section over its byte ceiling is the client's fault, not a framing fault: {err:?}"
        );
        assert!(
            format!("{err}").contains("bytes of text"),
            "the refusal must name the ceiling that fired: {err}"
        );

        // The other direction, same reader, same count: a real batch section
        // is nowhere near it. A ceiling that refused this would refuse every
        // push of more than a handful of objects.
        let mut wire = Vec::new();
        for seq in 0..100u32 {
            let oid = format!("{:064x} {}", seq, 4096);
            gunnar_pktline::write_data(&mut wire, oid.as_bytes()).expect("frame");
        }
        gunnar_pktline::write_flush(&mut wire).expect("flush");
        let lines = read_lines(&mut wire.as_slice(), 100, "the batch objects")
            .expect("a real batch section must be read");
        assert_eq!(lines.len(), 100);
        // …and the ceiling is genuinely above it rather than equal to it: the
        // real section is a fraction of what is allowed.
        let used: usize = lines.iter().map(|l| l.len()).sum();
        assert!(
            used * 3 < 100 * MAX_LINE_BYTES,
            "the byte ceiling is too close to a real batch section ({used} bytes used of {})",
            100 * MAX_LINE_BYTES
        );
    }

    #[test]
    fn args_are_parsed_including_the_empty_value_form() {
        let args: Vec<String> = ["size=17", "id=", "token=abc", "bare"]
            .iter()
            .map(|s| s.to_string())
            .collect();
        assert_eq!(arg(&args, "size"), Some("17"));
        assert_eq!(arg(&args, "id"), Some(""));
        assert_eq!(arg(&args, "token"), Some("abc"));
        assert_eq!(arg(&args, "bare"), Some(""));
        assert_eq!(arg(&args, "missing"), None);
        // The prefix trap: `siz` must not match `size=17`, or every lookup
        // becomes a prefix search and `id` would find `identity=`.
        assert_eq!(arg(&args, "siz"), None);
        assert_eq!(arg(&args, "toke"), None);
    }

    #[test]
    fn the_data_framer_never_buffers_more_than_one_chunk() {
        let mut out = Vec::new();
        let mut framer = DataFramer::new(&mut out);
        let payload = vec![b'x'; CHUNK_BYTES * 3 + 7];
        std::io::Write::write_all(&mut framer, &payload).unwrap();
        assert!(
            framer.buf.capacity() <= CHUNK_BYTES * 2,
            "the framer grew to {} bytes for a {}-byte object",
            framer.buf.capacity(),
            payload.len()
        );
        let framed = framer.finish().unwrap();
        assert_eq!(framed, payload.len() as u64);

        // And the frames decode back to exactly the payload.
        let mut decoder = gunnar_pktline::Decoder::new();
        decoder.push(&out);
        let mut back = Vec::new();
        for packet in decoder.drain().unwrap() {
            back.extend_from_slice(packet.payload().expect("data packets only"));
        }
        assert_eq!(back, payload);
    }

    #[test]
    fn reading_a_section_reports_which_terminator_arrived() {
        let mut input: &[u8] = b"000bsize=1\n0001";
        let mut reader: &mut dyn BufRead = &mut input;
        let (args, end) = read_args(&mut reader, 8, "put-object").unwrap();
        assert_eq!(args, vec!["size=1".to_string()]);
        assert_eq!(end, SectionEnd::Delim);

        let mut input: &[u8] = b"000bsize=1\n0000";
        let mut reader: &mut dyn BufRead = &mut input;
        let (_, end) = read_args(&mut reader, 8, "put-object").unwrap();
        assert_eq!(end, SectionEnd::Flush);
    }

    #[test]
    fn the_argument_cap_fires() {
        let mut framed = Vec::new();
        for i in 0..10 {
            gunnar_pktline::write_text(&mut framed, format!("a{i}=1").as_bytes()).unwrap();
        }
        gunnar_pktline::write_flush(&mut framed).unwrap();
        let mut slice: &[u8] = &framed;
        let mut reader: &mut dyn BufRead = &mut slice;
        let err = read_args(&mut reader, 4, "batch").expect_err("over the cap");
        assert_eq!(err.status(), 400);

        // The identity-value half: under the cap it must succeed, or the test
        // above would pass against a reader that refused everything.
        let mut slice: &[u8] = &framed;
        let mut reader: &mut dyn BufRead = &mut slice;
        assert_eq!(read_args(&mut reader, 32, "batch").unwrap().0.len(), 10);
    }
}