gunnar-lfs 1.6.0

Git LFS over pure SSH: the git-lfs 3.0 `git-lfs-transfer` protocol, server side, streaming and without HTTP.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
//! 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);
    }
}