gunnar-server 2.0.0

The gunnar git server: SSH and smart-HTTP git transports and the gRPC control plane. The appliance half, extracted from gunnar-cli so the server code lives in its own crate.
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
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
//! **Where a pushed packfile ends**, and why the server has to know.
//!
//! # The deadlock this module exists to make impossible
//!
//! `receive-pack` reads a command list of pkt-lines, a flush-pkt, and then a
//! **raw packfile on the same stream**. Nothing frames the pack: no length, no
//! terminator, no flush. The only thing that says where it stops is the pack
//! itself.
//!
//! [`super::read_pushed_pack`] used to end it with `std::io::copy`, i.e. at end
//! of stream — and it said so out loud:
//!
//! > *"this reads to end of stream, which is what a push over SSH or stdio
//! > gives — nothing follows the pack on that channel."*
//!
//! Nothing follows the pack, and that is exactly the problem. A `receive-pack`
//! client does **not** hang up after the pack: it holds the channel open and
//! waits for the status report, which is the next thing the server owes it. So
//! the server waits for an EOF the client will only send after it has been
//! answered, and the client waits for an answer the server will only send after
//! the EOF.
//!
//! MEASURED on the 4-core laptop, 2026-08-11, `gunnar.real_repo_clone` pushing the 12 750-object
//! `nornir` mirror into a real `gunnar serve` over SSH with gunnar's own client:
//!
//! ```text
//! 22:46:53  git.receive_pack.receiving
//!           …452 seconds…    0 s of server CPU, 23 s of client CPU (all of it
//!                            spent before the pack), one ESTAB socket with
//!                            Recv-Q and Send-Q 0 at BOTH ends
//! 22:54:25  (the client was KILLED)
//! 22:54:25  store.znippy.ingested  objects=12750 bytes=31225815 elapsed_ms=452261
//! 22:54:27  git.receive_pack.updated  refs_ok=54 refs_rejected=0
//! ```
//!
//! The server already held the whole pack and every one of the 12 750 objects.
//! It was blocked in a read for bytes that do not exist.
//!
//! # Why nobody noticed for a month
//!
//! Every client the suite drives happens to close its write end after the pack:
//!
//! * **stock `git push`** does — `send-pack.c` hands `out` to `pack_objects`'s
//!   child and the parent's copy is closed with it (*"Closed by pack_objects()
//!   via start_command()"*). `gunnar.vs_git_push` and every push in
//!   `gunnar-cli/tests/ssh_end_to_end.rs` is a stock-git push;
//! * **smart HTTP** does, because the request body ends.
//!
//! gunnar's own client does not, and is right not to: nothing in the protocol
//! requires it, and `git index-pack --stdin` — what real `git-receive-pack`
//! reads a push with — stops at the trailer rather than at EOF. This module is
//! that stop.
//!
//! # What it costs
//!
//! A packfile is self-delimiting only if you walk it, and walking it means
//! finding the end of each entry's zlib stream, which means inflating. That is
//! the same inflate `index-pack` pays, once, streaming, into a 64 KiB scratch
//! buffer whose output is thrown away. Nothing here allocates per entry except
//! the decompressor, and nothing here holds the pack.
//!
//! # What it does NOT do
//!
//! It does not verify the trailing checksum, resolve deltas, or name a single
//! object. It answers one question — *how many of these bytes are the pack* —
//! and leaves storage to the engine, which is the half that was never broken.

use anyhow::{bail, Result};
use flate2::{Decompress, FlushDecompress, Status};

/// How much inflated output is thrown away at a time.
///
/// The output is discarded, so this trades syscall-free loop iterations against
/// a stack-sized allocation and nothing else. 64 KiB is one `Decompress` call
/// per 64 KiB of *object* content, which for a real pack is a few thousand
/// calls per push.
const SCRATCH_BYTES: usize = 64 * 1024;

/// Every pack entry begins with a type in bits 6..4 of its first byte.
mod kind {
    pub(super) const OFS_DELTA: u8 = 6;
    pub(super) const REF_DELTA: u8 = 7;
    /// Reserved by the format and never emitted; `0` is not a type either.
    pub(super) const RESERVED: u8 = 5;
}

/// Where the scanner is inside the packfile.
enum Phase {
    /// `PACK`, the version, and the object count: twelve bytes, and they may
    /// arrive one at a time.
    Header {
        seen: usize,
        buf: [u8; 12],
    },
    /// The type-and-size varint at the head of an entry. `first` is still true
    /// until the byte that carries the type has been seen.
    EntryHeader {
        first: bool,
        kind: u8,
    },
    /// An `OBJ_OFS_DELTA`'s base offset: a varint, skipped rather than decoded.
    OfsBase,
    /// An `OBJ_REF_DELTA`'s base object id: one hash, skipped.
    RefBase {
        left: usize,
    },
    /// The entry's zlib stream. The decompressor — [`PackScanner::stream`],
    /// reset once per entry rather than rebuilt — is the only thing that can say
    /// where it ends.
    Data,
    /// The pack's trailing checksum.
    Trailer {
        left: usize,
    },
    Done,
}

/// **A streaming packfile delimiter**: feed it bytes, it says how many of them
/// were still the pack.
///
/// The contract is deliberately narrow so it can be driven off a `BufRead`
/// without ever consuming a byte that is not the caller's to consume:
/// [`PackScanner::feed`] is given a *peeked* slice and returns a prefix length,
/// so the caller consumes exactly what the pack owns and leaves the rest in the
/// reader.
pub(super) struct PackScanner {
    phase: Phase,
    /// Entries not yet seen. From the pack's own header.
    entries_left: u32,
    hash_len: usize,
    /// Inflated output, immediately discarded. One buffer for the whole pack.
    scratch: Vec<u8>,
    /// One decompressor for the whole pack, `reset` between entries. A `new`
    /// per entry would be one inflate-state allocation per object, and a real
    /// push is 12 750 of them.
    stream: Decompress,
    /// The pack's declared format version, once the header has landed.
    version: u32,
    /// The pack's declared object count, once the header has landed.
    num_objects: u32,
    /// Bytes of pack seen so far, for error messages that can be acted on.
    offset: u64,
}

impl PackScanner {
    /// A scanner for a pack whose object ids are `hash_len` bytes wide.
    pub(super) fn new(hash_len: usize) -> Self {
        PackScanner {
            phase: Phase::Header {
                seen: 0,
                buf: [0u8; 12],
            },
            entries_left: 0,
            hash_len,
            scratch: vec![0u8; SCRATCH_BYTES],
            stream: Decompress::new(true),
            version: 0,
            num_objects: 0,
            offset: 0,
        }
    }

    /// Has the trailing checksum been seen? Nothing after this is the pack's.
    pub(super) fn done(&self) -> bool {
        matches!(self.phase, Phase::Done)
    }

    /// The header's two numbers, valid once at least twelve bytes have been fed.
    pub(super) fn header(&self) -> (u32, u32) {
        (self.version, self.num_objects)
    }

    /// What this scanner was in the middle of, for a truncation message that
    /// says which part of the pack never arrived.
    pub(super) fn waiting_for(&self) -> &'static str {
        match self.phase {
            Phase::Header { .. } => "the 12-byte pack header",
            Phase::EntryHeader { .. } => "an object entry's type-and-size header",
            Phase::OfsBase => "an ofs-delta entry's base offset",
            Phase::RefBase { .. } => "a ref-delta entry's base object id",
            Phase::Data => "an object entry's compressed data",
            Phase::Trailer { .. } => "the pack's trailing checksum",
            Phase::Done => "nothing: the pack is complete",
        }
    }

    /// How many bytes of `chunk` belong to the packfile.
    ///
    /// Returns `chunk.len()` for as long as the pack continues, and a short
    /// count exactly once: on the chunk that carries the last byte of the
    /// trailer. After that it returns `0` for ever, and [`PackScanner::done`]
    /// is true.
    pub(super) fn feed(&mut self, chunk: &[u8]) -> Result<usize> {
        // Destructured so the decompressor and the scratch buffer can be
        // borrowed at the same time; they are different fields, but a `match`
        // on `self.phase` would not let the compiler see that.
        let Self {
            phase,
            entries_left,
            hash_len,
            scratch,
            stream,
            version,
            num_objects,
            offset,
        } = self;
        let hash_len = *hash_len;
        let mut at = 0usize;

        while at < chunk.len() {
            match phase {
                Phase::Done => break,

                Phase::Header { seen, buf } => {
                    let want = 12 - *seen;
                    let take = want.min(chunk.len() - at);
                    buf[*seen..*seen + take].copy_from_slice(&chunk[at..at + take]);
                    *seen += take;
                    at += take;
                    if *seen < 12 {
                        continue;
                    }
                    if &buf[..4] != b"PACK" {
                        bail!(
                            "a push must begin with the four bytes `PACK`; this one begins \
                             {:?}. The command list and the pack are on one stream, so a \
                             wrong magic here means the pkt-line framing left the stream in \
                             the wrong place, not that the pack is corrupt",
                            &buf[..4]
                        );
                    }
                    *version = u32::from_be_bytes([buf[4], buf[5], buf[6], buf[7]]);
                    *num_objects = u32::from_be_bytes([buf[8], buf[9], buf[10], buf[11]]);
                    *entries_left = *num_objects;
                    *phase = if *entries_left == 0 {
                        Phase::Trailer { left: hash_len }
                    } else {
                        Phase::EntryHeader {
                            first: true,
                            kind: 0,
                        }
                    };
                }

                Phase::EntryHeader { first, kind } => {
                    let byte = chunk[at];
                    at += 1;
                    if *first {
                        *kind = (byte >> 4) & 0b111;
                        if *kind == 0 || *kind == kind::RESERVED {
                            bail!(
                                "object entry {} of this push declares type {}, which no pack \
                                 may contain",
                                *num_objects - *entries_left + 1,
                                *kind
                            );
                        }
                        *first = false;
                    }
                    if byte & 0b1000_0000 == 0 {
                        *phase = match *kind {
                            kind::OFS_DELTA => Phase::OfsBase,
                            kind::REF_DELTA => Phase::RefBase { left: hash_len },
                            _ => {
                                stream.reset(true);
                                Phase::Data
                            }
                        };
                    }
                }

                // git's own offset encoding, and this only has to find its end:
                // every byte but the last has the continuation bit set, exactly
                // as in the size varint above.
                Phase::OfsBase => {
                    let byte = chunk[at];
                    at += 1;
                    if byte & 0b1000_0000 == 0 {
                        stream.reset(true);
                        *phase = Phase::Data;
                    }
                }

                Phase::RefBase { left } => {
                    let take = (*left).min(chunk.len() - at);
                    at += take;
                    *left -= take;
                    if *left == 0 {
                        stream.reset(true);
                        *phase = Phase::Data;
                    }
                }

                Phase::Data => {
                    let before = stream.total_in();
                    let produced_before = stream.total_out();
                    let status = stream
                        .decompress(&chunk[at..], scratch, FlushDecompress::None)
                        .map_err(|err| {
                            anyhow::anyhow!(
                                "object entry {} of this push does not inflate at pack offset \
                                 {}: {err}",
                                *num_objects - *entries_left + 1,
                                *offset + at as u64
                            )
                        })?;
                    let used = (stream.total_in() - before) as usize;
                    at += used;
                    match status {
                        Status::StreamEnd => {
                            *entries_left -= 1;
                            *phase = if *entries_left == 0 {
                                Phase::Trailer { left: hash_len }
                            } else {
                                Phase::EntryHeader {
                                    first: true,
                                    kind: 0,
                                }
                            };
                        }
                        // No input consumed **and** no output produced, with
                        // input still available: the stream cannot advance and
                        // looping would spin. Both halves are needed — inflate
                        // may flush buffered output without taking a byte, and
                        // that is progress, not a stall. This is a malformed
                        // entry, not a short read.
                        Status::Ok | Status::BufError
                            if used == 0 && stream.total_out() == produced_before =>
                        {
                            bail!(
                                "object entry {} of this push stopped inflating at pack offset \
                                 {} with {} byte(s) still to read, which no well-formed zlib \
                                 stream does",
                                *num_objects - *entries_left + 1,
                                *offset + at as u64,
                                chunk.len() - at
                            );
                        }
                        Status::Ok | Status::BufError => {}
                    }
                }

                Phase::Trailer { left } => {
                    let take = (*left).min(chunk.len() - at);
                    at += take;
                    *left -= take;
                    if *left == 0 {
                        *phase = Phase::Done;
                        break;
                    }
                }
            }
        }

        *offset += at as u64;
        Ok(at)
    }
}

/// **The packfile every test in this crate is delimited against.**
///
/// Built by hand rather than by a pack writer, and that is the point: this
/// crate has no pack writer, and a fixture produced by the same library the
/// code under test used would prove that gunnar agrees with itself. These are
/// the bytes the format specifies.
#[cfg(test)]
pub(super) mod fixture {
    use std::io::Write as _;

    use super::kind;

    /// A zlib stream carrying `content`, the way a pack entry carries it.
    pub(in crate::storage) fn deflate(content: &[u8]) -> Vec<u8> {
        let mut encoder =
            flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
        encoder.write_all(content).expect("a Vec sink cannot fail");
        encoder.finish().expect("a Vec sink cannot fail")
    }

    /// A pack entry's type-and-size varint.
    pub(in crate::storage) fn entry_header(kind: u8, size: usize) -> Vec<u8> {
        let mut out = Vec::new();
        let mut byte = ((kind & 0b111) << 4) | (size & 0x0f) as u8;
        let mut rest = size >> 4;
        while rest > 0 {
            out.push(byte | 0b1000_0000);
            byte = (rest & 0x7f) as u8;
            rest >>= 7;
        }
        out.push(byte);
        out
    }

    /// git's negative-offset encoding, for an `OBJ_OFS_DELTA` base.
    pub(in crate::storage) fn ofs_base(mut n: u64) -> Vec<u8> {
        let mut out = vec![(n & 0x7f) as u8];
        while n > 127 {
            n = (n >> 7) - 1;
            out.insert(0, 0b1000_0000 | (n & 0x7f) as u8);
        }
        out
    }

    /// **A real packfile, with one of each entry shape the scanner must skip.**
    ///
    /// Four entries: a plain blob, a *large* blob (so its size varint is more
    /// than one byte), an `OBJ_OFS_DELTA` and an `OBJ_REF_DELTA`. A scanner
    /// that only handled the plain case would run off the end on entry three,
    /// which is the point of putting all four in the fixture.
    ///
    /// The trailer is twenty bytes that are not a checksum. Nothing here
    /// verifies one, and pretending to would make the fixture's honesty depend
    /// on a hasher the delimiter does not use.
    pub(in crate::storage) fn fixture_pack() -> Vec<u8> {
        // Pseudo-random rather than 5 000 `x`: a run of one byte deflates to
        // thirty and the "large" entry would then be smaller than the small
        // one, so nothing would cross a 997-byte read boundary and the
        // chunked-input paths would never run.
        let mut seed = 0x2545_F491_4F6C_DD1Du64;
        let big: Vec<u8> = (0..5000)
            .map(|_| {
                seed ^= seed << 13;
                seed ^= seed >> 7;
                seed ^= seed << 17;
                (seed >> 24) as u8
            })
            .collect();
        let mut out = Vec::new();
        out.extend_from_slice(b"PACK");
        out.extend_from_slice(&2u32.to_be_bytes());
        out.extend_from_slice(&4u32.to_be_bytes());

        out.extend_from_slice(&entry_header(3, 5));
        out.extend_from_slice(&deflate(b"hello"));

        out.extend_from_slice(&entry_header(3, big.len()));
        out.extend_from_slice(&deflate(&big));

        out.extend_from_slice(&entry_header(kind::OFS_DELTA, 9));
        out.extend_from_slice(&ofs_base(4321));
        out.extend_from_slice(&deflate(b"delta-ofs"));

        out.extend_from_slice(&entry_header(kind::REF_DELTA, 9));
        out.extend_from_slice(&[0xABu8; 20]);
        out.extend_from_slice(&deflate(b"delta-ref"));

        out.extend_from_slice(&[0x11u8; 20]);
        out
    }
}

#[cfg(test)]
mod tests {
    use super::fixture::{deflate, entry_header, fixture_pack};
    use super::*;

    /// ANTI-VACUITY. Every assertion below is *"the scanner stopped at byte
    /// `pack.len()`"*, which a scanner that stopped at byte 12 would also
    /// satisfy against a 12-byte fixture. This pins the fixture's shape so the
    /// tests underneath are about the scanner and not about a tiny pack.
    #[test]
    fn the_fixture_is_a_pack_with_four_entries_of_four_shapes() {
        let pack = fixture_pack();
        assert_eq!(&pack[..4], b"PACK");
        assert_eq!(
            u32::from_be_bytes([pack[8], pack[9], pack[10], pack[11]]),
            4
        );
        assert!(
            entry_header(3, 5000).len() > 1,
            "the large entry is in the fixture so a multi-byte size varint is walked; that \
             size now fits in one byte and the varint loop is untested"
        );
        assert!(
            pack.len() > 4000,
            "the large entry is also what makes the input span several reads; the whole \
             fixture is only {} bytes, so every chunked path is skipped",
            pack.len()
        );
    }

    /// **The whole point, in one line: the scanner stops on the last byte of
    /// the trailer and not one byte later.**
    #[test]
    fn the_scanner_stops_on_the_trailer_and_not_after_it() {
        let pack = fixture_pack();
        let mut stream = pack.clone();
        stream.extend_from_slice(b"THIS IS NOT THE PACK");

        let mut scanner = PackScanner::new(20);
        let used = scanner.feed(&stream).expect("a well-formed pack");
        assert_eq!(
            used,
            pack.len(),
            "the scanner claimed {used} of {} pack bytes; anything past {} is the report \
             the client is waiting for, and reading it is the deadlock",
            stream.len(),
            pack.len()
        );
        assert!(scanner.done());
        assert_eq!(scanner.header(), (2, 4));
    }

    /// The same answer, one byte at a time: a pack does not arrive in one
    /// `read`, and a state machine that only works on whole buffers is a state
    /// machine that works in tests and hangs on a network.
    #[test]
    fn the_scanner_gives_the_same_answer_byte_by_byte() {
        let pack = fixture_pack();
        let mut stream = pack.clone();
        stream.extend_from_slice(b"TRAILING RUBBISH");

        let mut scanner = PackScanner::new(20);
        let mut consumed = 0usize;
        for i in 0..stream.len() {
            if scanner.done() {
                break;
            }
            consumed += scanner.feed(&stream[i..i + 1]).expect("a well-formed pack");
        }
        assert!(scanner.done(), "the scanner never reached the trailer");
        assert_eq!(consumed, pack.len());
    }

    /// A deletions-only push carries a pack with no entries at all, and the
    /// header is immediately followed by the trailer.
    #[test]
    fn an_empty_pack_is_header_plus_trailer() {
        let mut pack = Vec::new();
        pack.extend_from_slice(b"PACK");
        pack.extend_from_slice(&2u32.to_be_bytes());
        pack.extend_from_slice(&0u32.to_be_bytes());
        pack.extend_from_slice(&[0x22u8; 20]);
        let mut stream = pack.clone();
        stream.extend_from_slice(b"after");

        let mut scanner = PackScanner::new(20);
        assert_eq!(scanner.feed(&stream).unwrap(), pack.len());
        assert!(scanner.done());
    }

    /// sha256 repositories have 32-byte ids, so the ref-delta base and the
    /// trailer are both wider. A hard-coded 20 passes every test above and
    /// desynchronises here.
    #[test]
    fn the_hash_width_is_the_one_the_repository_uses() {
        let mut pack = Vec::new();
        pack.extend_from_slice(b"PACK");
        pack.extend_from_slice(&2u32.to_be_bytes());
        pack.extend_from_slice(&1u32.to_be_bytes());
        pack.extend_from_slice(&entry_header(kind::REF_DELTA, 4));
        pack.extend_from_slice(&[0xCDu8; 32]);
        pack.extend_from_slice(&deflate(b"base"));
        pack.extend_from_slice(&[0x33u8; 32]);
        let mut stream = pack.clone();
        stream.extend_from_slice(b"and the report");

        let mut scanner = PackScanner::new(32);
        assert_eq!(scanner.feed(&stream).unwrap(), pack.len());
        assert!(scanner.done());
    }

    /// A truncated pack must leave the scanner **not** done, so the caller can
    /// say "the client hung up mid-pack" instead of storing half a push.
    #[test]
    fn a_truncated_pack_never_completes() {
        let pack = fixture_pack();
        let mut scanner = PackScanner::new(20);
        let short = &pack[..pack.len() - 5];
        assert_eq!(scanner.feed(short).unwrap(), short.len());
        assert!(!scanner.done());
        assert_eq!(scanner.waiting_for(), "the pack's trailing checksum");
    }

    /// Bytes that are not a pack are refused at the magic, before any of the
    /// state machine runs.
    #[test]
    fn something_that_is_not_a_pack_is_refused_by_name() {
        let mut scanner = PackScanner::new(20);
        let err = scanner
            .feed(b"0009done\n0000AAAA")
            .expect_err("`0009done` is not a packfile");
        assert!(
            format!("{err}").contains("`PACK`"),
            "the refusal must name the magic it wanted: {err}"
        );
    }
}