gunnar-wire 1.5.1

git smart-protocol state machines: upload-pack (v2 with a v0 fallback) and receive-pack (v0), transport-agnostic
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
//! Reading and writing the framed stream, and the sideband multiplexer.
//!
//! Everything here works on `dyn BufRead` / `dyn Write` and nothing else. That
//! is the transport-agnosticism the crate promises: an SSH channel, a pipe, a
//! `Vec<u8>` and a test fixture are all the same to these functions, so the
//! state machines can be driven from a unit test with no socket in sight.

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

use gunnar_pktline::{
    decode, Decoded, Packet, BAND_ERROR, BAND_PACK, BAND_PROGRESS, SIDEBAND_CHUNK,
};

use crate::{Error, Result};

/// **How much a transport must buffer under these writers**, and why one
/// sideband packet is the unit that decides it.
///
/// [`SidebandWriter`] hands the transport one packet at a time, and
/// `gunnar_pktline::write_sideband` hands it that packet as **three** calls: a
/// 4-byte length, a 1-byte band, then [`SIDEBAND_CHUNK`] bytes of payload. Put a
/// `BufWriter` under that and its capacity decides how those three become
/// writes on the stream underneath:
///
/// | capacity | writes per 65 520-byte packet | smallest write |
/// |---|---|---|
/// | 8 KiB (`BufWriter::new`) | **2** | **5 bytes** |
/// | this constant | 1 per four packets | 262 080 bytes |
///
/// The 8 KiB case is not a small buffer, it is a **defeated** one: 65 515 bytes
/// exceeds the capacity, so `BufWriter` flushes the 5 bytes it is holding as a
/// write of its own and passes the payload straight through. Every packet
/// therefore costs two writes, one of them five bytes.
///
/// That matters because of what is under the transport. `gunnar-cli`'s
/// `WireTransport` runs this state machine on a `spawn_blocking` thread writing
/// into a `SyncIoBridge` over a `tokio::io::duplex`, so **one write is one
/// hand-off** — a `block_on`, a waker, and, when the reader is parked, a futex
/// pair. `gunnar-http`'s `Streamed` body doc records the other half of the same
/// arithmetic measured 2026-08-04: 7 307–10 936 futex calls per clone of
/// the 28 893-object `year` corpus, and −25% of them from widening the
/// **reader**'s frame alone. The writer was still handing over 64 KiB at a time
/// in two pieces.
///
/// 256 KiB is four whole packets, which is where the ratio stops improving: the
/// duplex window this feeds is 256 KiB (`HttpConfig::stream_window`), so a
/// larger buffer would only split its flush against a window that cannot take
/// it. It is deliberately **not** a copy of that number — a transport buffer and
/// a flow-control window are different things that happen to agree — but it must
/// stay at or above one whole packet or the 5-byte write comes back.
/// `transport_buffer_holds_whole_sideband_packets` asserts that on the applied
/// output: it counts writes, not capacities.
pub const TRANSPORT_BUFFER_BYTES: usize = 256 * 1024;

/// Read exactly one pkt-line, or `None` at a clean end of stream.
///
/// **Exact reads, never buffered read-ahead.** `receive-pack` follows its
/// framed command list with a raw packfile on the very same stream, so a reader
/// that pulled "whatever was available" would swallow the head of the pack and
/// the pack's trailing checksum would then fail over bytes nobody can point at.
/// Reading the 4-byte header and then exactly the declared payload leaves the
/// stream positioned on the first pack byte, which is what
/// [`crate::receive_pack`] relies on.
pub(crate) fn read_packet(input: &mut dyn BufRead) -> Result<Option<Packet>> {
    let mut head = [0u8; 4];
    match read_up_to(input, &mut head)? {
        0 => return Ok(None),
        4 => {}
        _ => return Err(Error::UnexpectedEof("a pkt-line header")),
    }

    // `decode` validates the header (hex digits, the reserved `0003`, the
    // length ceiling) and answers outright for the three control packets,
    // whose whole line is those four bytes. `Incomplete` here therefore means
    // "a valid header for a data packet whose payload has not arrived", which
    // is the only case where the length has to be re-read below.
    match decode(&head)? {
        Decoded::Packet(pkt, _) => return Ok(Some(pkt)),
        Decoded::Incomplete => {}
    }

    let len = line_length(&head);
    let mut line = vec![0u8; len];
    line[..4].copy_from_slice(&head);
    input.read_exact(&mut line[4..]).map_err(|err| {
        if err.kind() == std::io::ErrorKind::UnexpectedEof {
            Error::UnexpectedEof("a pkt-line payload")
        } else {
            Error::Io(err)
        }
    })?;

    match decode(&line)? {
        Decoded::Packet(pkt, used) if used == len => Ok(Some(pkt)),
        _ => Err(Error::protocol(format!(
            "a {len}-byte pkt-line did not decode as one packet"
        ))),
    }
}

/// One pkt-line, refusing a clean end of stream.
pub(crate) fn expect_packet(input: &mut dyn BufRead, what: &'static str) -> Result<Packet> {
    read_packet(input)?.ok_or(Error::UnexpectedEof(what))
}

/// The declared total length of a line whose header `decode` already accepted.
///
/// Four hex digits, which `decode` has necessarily just parsed successfully;
/// it returned `Incomplete` rather than `BadLength`. The parse is repeated here
/// only because `Decoded::Incomplete` carries no length; nothing else in gunnar
/// parses a pkt-line header.
fn line_length(head: &[u8; 4]) -> usize {
    head.iter().fold(0usize, |acc, &b| {
        let nibble = match b {
            b'0'..=b'9' => b - b'0',
            b'a'..=b'f' => b - b'a' + 10,
            b'A'..=b'F' => b - b'A' + 10,
            _ => 0,
        };
        (acc << 4) | nibble as usize
    })
}

fn read_up_to(input: &mut dyn BufRead, buf: &mut [u8]) -> Result<usize> {
    let mut filled = 0;
    while filled < buf.len() {
        match input.read(&mut buf[filled..])? {
            0 => break,
            n => filled += n,
        }
    }
    Ok(filled)
}

/// Read pkt-lines until a flush-pkt, returning the payloads with their trailing
/// newline stripped.
///
/// A clean end of stream before the flush is an error: every section in both
/// protocols is flush-terminated, so an absent flush means a truncated peer.
///
/// `budget` is the caller's ceiling on how many of the peer's bytes this may
/// hold. The loop terminates on a flush-pkt the **peer** chooses to send, so
/// without one the only bound on `lines` is the peer's patience.
pub(crate) fn read_section(
    input: &mut dyn BufRead,
    what: &'static str,
    budget: &mut crate::limits::Budget,
) -> Result<Vec<Vec<u8>>> {
    let mut lines = Vec::new();
    loop {
        match expect_packet(input, what)? {
            Packet::Flush => return Ok(lines),
            Packet::Data(data) => {
                budget.spend(data.len(), what)?;
                let text = data.strip_suffix(b"\n").unwrap_or(&data);
                lines.push(text.to_vec());
            }
            other => {
                return Err(Error::protocol(format!(
                    "unexpected {other:?} while reading {what}"
                )))
            }
        }
    }
}

/// Multiplexes a byte stream onto one sideband, buffering up to a full line.
///
/// The band byte is part of the pkt-line payload, so a full sideband line
/// carries `SIDEBAND_CHUNK` bytes of data and not `MAX_PAYLOAD`; that
/// subtraction is made once, in `gunnar-pktline`, and this type only has to
/// respect the resulting chunk size.
pub(crate) struct SidebandWriter<'a> {
    out: &'a mut dyn Write,
    band: u8,
    buf: Vec<u8>,
}

impl<'a> SidebandWriter<'a> {
    pub(crate) fn new(out: &'a mut dyn Write, band: u8) -> Self {
        SidebandWriter {
            out,
            band,
            buf: Vec::with_capacity(SIDEBAND_CHUNK),
        }
    }

    fn emit(&mut self) -> std::io::Result<()> {
        if self.buf.is_empty() {
            return Ok(());
        }
        gunnar_pktline::write_sideband(&mut self.out, self.band, &self.buf).map_err(as_io)?;
        self.buf.clear();
        Ok(())
    }
}

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

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

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

/// Where a packfile goes: straight down the wire, or on sideband 1.
///
/// Protocol v2 always sidebands the packfile; protocol v0 only does when the
/// client asked for `side-band-64k`. Both are the same `Write` to the pack
/// writer, so the pack streams either way instead of being buffered whole,
/// which for a clone is the difference between a constant and a repository-sized
/// allocation.
pub(crate) enum PackOutput<'a> {
    Raw(&'a mut dyn Write),
    Sideband(SidebandWriter<'a>),
}

impl<'a> PackOutput<'a> {
    pub(crate) fn new(out: &'a mut dyn Write, sideband: bool) -> Self {
        if sideband {
            PackOutput::Sideband(SidebandWriter::new(out, BAND_PACK))
        } else {
            PackOutput::Raw(out)
        }
    }
}

impl Write for PackOutput<'_> {
    fn write(&mut self, data: &[u8]) -> std::io::Result<usize> {
        match self {
            PackOutput::Raw(out) => out.write(data),
            PackOutput::Sideband(out) => out.write(data),
        }
    }

    fn flush(&mut self) -> std::io::Result<()> {
        match self {
            PackOutput::Raw(out) => out.flush(),
            PackOutput::Sideband(out) => out.flush(),
        }
    }
}

/// Write one text pkt-line to an unsized writer.
///
/// `gunnar-pktline`'s encoders are generic over `W: Write`, which is right for
/// a leaf crate but means `dyn Write` (the shape every entry point in this
/// crate takes) needs a reborrow at each call. These five wrappers make that
/// reborrow once instead of at ninety call sites.
pub(crate) fn text(out: &mut dyn Write, line: &[u8]) -> Result<()> {
    // The reborrow needs a named binding: `&mut *out` on its own makes
    // inference pick `W = dyn Write`, which is unsized.
    let mut sink: &mut dyn Write = out;
    gunnar_pktline::write_text(&mut sink, line)?;
    Ok(())
}

/// Write one data pkt-line verbatim, adding no newline.
pub(crate) fn data(out: &mut dyn Write, payload: &[u8]) -> Result<()> {
    let mut sink: &mut dyn Write = out;
    gunnar_pktline::write_data(&mut sink, payload)?;
    Ok(())
}

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

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

/// Write sideband lines on `band`.
pub(crate) fn sideband(out: &mut dyn Write, band: u8, payload: &[u8]) -> Result<()> {
    let mut sink: &mut dyn Write = out;
    gunnar_pktline::write_sideband(&mut sink, band, payload)?;
    Ok(())
}

/// Send a progress message on band 2, or nothing when progress is suppressed
/// or the client is not reading a sideband.
pub(crate) fn progress(out: &mut dyn Write, enabled: bool, message: &str) -> Result<()> {
    if !enabled || message.is_empty() {
        return Ok(());
    }
    sideband(out, BAND_PROGRESS, message.as_bytes())?;
    Ok(())
}

/// Send a fatal message on band 3. The client aborts on it.
pub(crate) fn sideband_error(out: &mut dyn Write, message: &str) -> Result<()> {
    let mut text = message.to_owned();
    if !text.ends_with('\n') {
        text.push('\n');
    }
    sideband(out, BAND_ERROR, text.as_bytes())?;
    Ok(())
}

/// Send an `ERR <message>` pkt-line, the way a v0 server reports a fatal
/// condition *before* any sideband exists. Public for the transport's
/// first-repository door, which refuses in the client's own protocol after the
/// advertisement and before any sideband.
pub fn err_packet(out: &mut dyn Write, message: &str) -> Result<()> {
    let mut line = String::from("ERR ");
    line.push_str(message);
    text(out, line.as_bytes())?;
    Ok(())
}

#[cfg(test)]
mod transport_buffer_tests {
    use std::io::{BufWriter, Write};

    use super::{PackOutput, TRANSPORT_BUFFER_BYTES};

    /// A sink that remembers how it was called, not what it was given.
    ///
    /// The applied output this file is about is the **sequence of writes**, not
    /// the bytes: the bytes are identical at every buffer size and a test that
    /// compared them would pass at 8 KiB and at 256 KiB alike.
    #[derive(Default)]
    struct Calls {
        sizes: Vec<usize>,
    }

    impl Write for Calls {
        fn write(&mut self, data: &[u8]) -> std::io::Result<usize> {
            self.sizes.push(data.len());
            Ok(data.len())
        }
        fn flush(&mut self) -> std::io::Result<()> {
            Ok(())
        }
    }

    /// Push `bytes` of packfile through the real sideband writer over a
    /// `BufWriter` of `capacity`, and report every write the stream underneath
    /// actually saw.
    fn writes_under(capacity: usize, bytes: usize) -> Vec<usize> {
        let payload = vec![0x5Au8; bytes];
        let mut calls = Calls::default();
        {
            let mut buffered = BufWriter::with_capacity(capacity, &mut calls);
            {
                let mut pack = PackOutput::new(&mut buffered, true);
                pack.write_all(&payload)
                    .expect("a counting sink cannot fail");
                pack.flush().expect("a counting sink cannot fail");
            }
            buffered.flush().expect("a counting sink cannot fail");
        }
        calls.sizes
    }

    /// **The write count is the measurement, and 8 KiB is the red arm.**
    ///
    /// One MiB of packfile is sixteen sideband packets. Under the default
    /// `BufWriter::new` capacity each of them costs two writes — a 5-byte one
    /// and a 65 515-byte one — because the payload does not fit and `BufWriter`
    /// dumps its header to get out of the way. Under
    /// [`TRANSPORT_BUFFER_BYTES`] four packets accumulate into one write and no
    /// write is small.
    ///
    /// In `gunnar-cli`'s `WireTransport` each of those writes is a `block_on`
    /// across a `tokio::io::duplex`, so this ratio is a hand-off ratio.
    #[test]
    fn transport_buffer_holds_whole_sideband_packets() {
        const MIB: usize = 1024 * 1024;

        let red = writes_under(8 * 1024, MIB);
        assert!(
            red.iter().any(|n| *n <= 8),
            "the 8 KiB arm is supposed to expose a 5-byte write per sideband packet and did not, \
             so this test is no longer measuring what it says: {red:?}"
        );
        assert!(
            red.len() >= 32,
            "1 MiB is 16 sideband packets and the 8 KiB arm should cost two writes each; it cost \
             {} — the red arm has stopped being red",
            red.len()
        );

        let green = writes_under(TRANSPORT_BUFFER_BYTES, MIB);
        assert!(
            green.iter().all(|n| *n > 8),
            "a buffer of {TRANSPORT_BUFFER_BYTES} bytes still handed the stream a write of {:?} \
             bytes, which is a pkt-line header on its own — the payload no longer fits the \
             buffer",
            green.iter().min()
        );
        assert!(
            green.len() * 4 <= red.len(),
            "{} write(s) at {TRANSPORT_BUFFER_BYTES} bytes against {} at 8 KiB is under the 4x \
             this buffer exists to buy",
            green.len(),
            red.len()
        );
    }
}