gunnar-server 3.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
//! **The mid-upload cut against the REAL store, and what the repository looks
//! like afterwards.**
//!
//! `gunnar-wire/tests/pack_ceiling.rs` proves the ceiling stops the transfer
//! and proves it against a byte count, which is the row. It proves it against
//! `MemGitServe`, where "nothing was stored" costs nothing to be true. This
//! file asks the question that only a real engine can answer: **a push aborted
//! halfway through must leave the repository exactly as it was, and usable.**
//!
//! That is not free. The shipping ingest
//! (`storage::read_pushed_pack_spooling`) reads the pack off the wire into a
//! spool file beside the archive and only then calls `put_pack`, so an abort
//! mid-read has to leave no journal row, no archive growth and no spool file —
//! three separate ways to leave litter, and the last one is a **full-size**
//! file under a name nothing sweeps.
//!
//! # Red first — MEASURED 2026-09-05
//!
//! With `Counting`'s two guards removed from `gunnar-wire`, this test fails on
//! the byte count:
//!
//! ```text
//! assertion `left == right` failed: the engine was handed 1049215 of 1049215
//! offered bytes, not the 65726 the 65536-byte ceiling allows
//!   left: 1049215
//!  right: 65726
//! ```
//!
//! …and with that assertion stood down, so is everything under it. The push
//! reports `unpack = Ok(())` and the repository grows by **1 049 250 bytes**:
//! the whole megabyte was read, spooled and appended to the archive of a
//! tenant who was over their cap by the second byte of it.

use std::io::BufRead;
use std::sync::Arc;

use gunnar_core::{NewRepo, RepoRef};
use gunnar_server::control_plane::repo_bytes;
use gunnar_server::storage::ObjectSet;
use gunnar_server::stores::ServerStores;
use gunnar_store::conformance::pack::PackBuilder;
use gunnar_wire::receive_pack::{AllowAll, Config};
use gunnar_wire::{PackCeiling, ReceivePack, RefStatus};

const CEILING: u64 = 64 * 1024;
const REFUSAL: &str = "plan 10gb would pass its pack quota with this push (cap 10737418240 \
                       bytes, 10737352704 already held, this push sent more than 65536 bytes): \
                       the transfer was stopped and nothing was stored; delete or buy more at \
                       https://gunnar.rs/billing/team";

/// A pack of incompressible blobs — see the note in
/// `gunnar-wire/tests/pack_ceiling.rs`: `PackBuilder` deflates, so a fixture of
/// repeated bytes would make this a test of zlib.
fn incompressible_pack(kind: gix_hash::Kind, blobs: usize, size: usize) -> Vec<u8> {
    let mut pb = PackBuilder::new(kind);
    let mut state: u64 = 0x2545_F491_4F6C_DD1D;
    for _ in 0..blobs {
        let mut body = vec![0u8; size];
        for byte in body.iter_mut() {
            state = state
                .wrapping_mul(6364136223846793005)
                .wrapping_add(1442695040888963407);
            *byte = (state >> 33) as u8;
        }
        pb.object(gix_object::Kind::Blob, body);
    }
    pb.build().expect("a pack of whole objects is legal").bytes
}

/// The v0 push preamble: one ref command, then a flush, then the pack.
///
/// Framed by hand rather than through `gunnar-pktline`, which `gunnar-server`
/// does not depend on and should not grow a dependency on for one test line.
/// The framing itself is `gunnar-wire`'s business and is pinned byte-for-byte
/// in `gunnar-wire/tests/receive_pack_wire_bytes.rs`; this is a fixture, not a
/// second implementation of anything (one implementation).
fn push_bytes(old: &str, new: &str, name: &str, caps: &str, pack: &[u8]) -> Vec<u8> {
    let mut line = format!("{old} {new} {name}").into_bytes();
    line.push(0);
    line.extend_from_slice(caps.as_bytes());
    line.push(b'\n');
    let mut out = format!("{:04x}", line.len() + 4).into_bytes();
    out.extend_from_slice(&line);
    out.extend_from_slice(b"0000");
    out.extend_from_slice(pack);
    out
}

/// A reader that records what was taken out of it. The applied output.
struct Tracked {
    inner: std::io::Cursor<Vec<u8>>,
    taken: u64,
}

impl std::io::Read for Tracked {
    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
        let read = std::io::Read::read(&mut self.inner, buf)?;
        self.taken += read as u64;
        Ok(read)
    }
}

impl BufRead for Tracked {
    fn fill_buf(&mut self) -> std::io::Result<&[u8]> {
        BufRead::fill_buf(&mut self.inner)
    }
    fn consume(&mut self, amount: usize) {
        BufRead::consume(&mut self.inner, amount);
        self.taken += amount as u64;
    }
}

/// Everything under `git_dir` that the spool would have left behind.
fn spool_leftovers(git_dir: &std::path::Path) -> Vec<String> {
    let root = git_dir.join("gunnar-git.znippy");
    let Ok(entries) = std::fs::read_dir(root) else {
        return Vec::new();
    };
    entries
        .flatten()
        .map(|e| e.file_name().to_string_lossy().into_owned())
        .filter(|name| name.starts_with("incoming-"))
        .collect()
}

#[test]
fn a_cut_push_stores_nothing_and_leaves_the_repository_usable() {
    let kind = gix_hash::Kind::Sha256;
    let dir = tempfile::tempdir().expect("tempdir");
    let stores = Arc::new(ServerStores::open(dir.path()).expect("stores"));
    stores
        .catalog()
        .create_namespace("team")
        .expect("namespace");
    let path = RepoRef::new("team", "one").expect("legal");
    let repo = stores
        .create_repo(NewRepo::new(path.clone()), false)
        .expect("create");
    let handle = stores.repo_store(&repo).expect("open");
    assert_eq!(
        handle.hash_kind(),
        kind,
        "the fixture pack and the repository must agree on the hash"
    );
    let git_dir = handle.git_dir().to_path_buf();
    let before = repo_bytes(&git_dir);

    // ── the push: a megabyte offered against a 64 KiB ceiling ─────────────
    let pack = incompressible_pack(kind, 8, 128 * 1024);
    assert!(
        pack.len() as u64 > CEILING * 8,
        "the fixture must dwarf the ceiling: {} bytes",
        pack.len()
    );
    let zero = kind.null().to_string();
    // The oid on the command line is one of the pack's own blobs. It does not
    // matter whether a ref may point at a blob: the assertion below is that the
    // reference did NOT move, and the accept twin lives in
    // `gunnar-wire/tests/pack_ceiling.rs` where a real store is not in the way.
    let input = push_bytes(
        &zero,
        &gix_hash::ObjectId::empty_blob(kind).to_string(),
        "refs/heads/main",
        "report-status object-format=sha256",
        &pack,
    );
    let offered = input.len() as u64;
    let mut reader = Tracked {
        inner: std::io::Cursor::new(input),
        taken: 0,
    };

    // **The shipping ingest**, not a test double: this is the spooling reader
    // and `put_pack` the appliance runs, which is the whole point of doing this
    // once outside `gunnar-wire`.
    let engine = stores.engine();
    let ingest = |pack: &mut dyn BufRead| -> gunnar_wire::Result<gunnar_wire::pack::IndexedPack> {
        let received = engine
            .ingest_push(&git_dir, kind, pack, ObjectSet::NotNeeded, None, None)
            .map_err(|err| {
                gunnar_wire::Error::Store(gunnar_store::Error::backend(format!("{err:#}")))
            })?;
        Ok(gunnar_wire::pack::IndexedPack {
            num_objects: received.num_objects,
            pack_id: gix_hash::ObjectId::try_from(received.pack_id.as_slice())
                .expect("a pack trailer is an object id"),
            index_id: gix_hash::ObjectId::null(kind),
            pack_version: gunnar_wire::pack::PackVersion::from_u32(received.version),
            hash_kind: kind,
            index_path: None,
            data_path: None,
            keep_path: None,
            stored_bytes: Some(received.pack_bytes),
        })
    };

    let rp = ReceivePack::new(handle.serve(), kind).with_config(Config {
        pack_ceiling: Some(PackCeiling {
            bytes: CEILING,
            refusal: REFUSAL.to_owned(),
            // One push against the real engine, so no shared standing to hold
            // it to: what this file proves is that a CUT push stores nothing,
            // and the cut is the same cut either way. The namespace's ledger is
            // proved in `gunnar-wire/tests/concurrent_pack_ceiling.rs` (the
            // budget) and `gunnar-ssh/tests/pack_ledger.rs` (that the front
            // door produces the standing at all).
            ledger: None,
        }),
        ..Config::default()
    });
    let mut out = Vec::new();
    let outcome = rp
        .serve(&mut reader, &mut out, &ingest, &AllowAll)
        .expect("serve")
        .expect("a push happened");

    // ── 1. the engine was cut mid-stream ──────────────────────────────────
    let commands = offered - pack.len() as u64;
    assert_eq!(
        reader.taken,
        commands + CEILING + 1,
        "the engine was handed {} of {offered} offered bytes, not the {} the {CEILING}-byte \
         ceiling allows",
        reader.taken,
        commands + CEILING + 1
    );
    assert_eq!(outcome.unpack.as_ref().unwrap_err(), REFUSAL);
    assert!(matches!(outcome.statuses[0], RefStatus::Rejected(_)));

    // ── 2. nothing was stored ─────────────────────────────────────────────
    let after = repo_bytes(&git_dir);
    assert_eq!(
        after.pack,
        before.pack,
        "the cut push grew the repository by {} bytes; the spool or the archive kept it",
        after.pack.saturating_sub(before.pack)
    );
    assert_eq!(
        spool_leftovers(&git_dir),
        Vec::<String>::new(),
        "a full-size spool file survived the abort, under a name nothing sweeps"
    );
    assert!(
        handle.serve().refs().expect("list refs").is_empty(),
        "the cut push moved a reference"
    );

    // ── 3. the repository still works ─────────────────────────────────────
    //
    // The assertion that separates "nothing was stored" from "the store was
    // wedged": a `put_pack` straight into the same open handle, and the object
    // count that follows it. A store left mid-transaction by the abort would
    // fail here, and every assertion above would still have been green.
    let mut pb = PackBuilder::new(kind);
    let good = pb.object(
        gix_object::Kind::Blob,
        b"the push after the refused one".to_vec(),
    );
    assert_eq!(good, 0);
    let good_pack = pb.build().expect("pack");
    handle
        .serve()
        .put_pack(&good_pack.bytes)
        .expect("the repository takes a push after an aborted one");
    let after_good = repo_bytes(&git_dir);
    assert!(
        after_good.pack > after.pack,
        "the good push stored nothing: {} then {}",
        after.pack,
        after_good.pack
    );
    drop(handle);
    stores.close_repo(&path);
}