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