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
//! **A store written by the old layout opens and works** (2026-10-06).
//!
//! Until 2026-10-06 every repository's object tail was a redb 2 database
//! (`gunnar-git.znippy/objects.tail`, 1.6 MB of disk once closed — see
//! `tests/repo_overhead.rs`). The tail is redb 4 now, and an old tail is
//! migrated the first time its store is opened (`znippy-plugin-git` 0.1.8,
//! `read_stack::migrate_redb2_tail`).
//!
//! The fixture is not synthetic: `fixtures/old-layout-store.bin.gz` is a whole
//! data directory written by the OLD build (gunnar `0aa66b4`, znippy-plugin-git
//! 0.1.6 from crates.io, redb 2.6.3) with `write_the_old_layout_fixture` below —
//! a namespace, a repository, one pushed commit, every store closed the way
//! `gunnar-server` closes them at exit. This test opens it with THIS build,
//! reads the commit back, pushes a second one on top, closes, and reopens.

use std::io::{BufRead, Read, Write};
use std::os::unix::fs::MetadataExt;
use std::path::{Path, PathBuf};

use gunnar_core::{NewRepo, RepoRef};
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::ReceivePack;

const FIXTURE: &str = concat!(
    env!("CARGO_MANIFEST_DIR"),
    "/tests/fixtures/old-layout-store.bin.gz"
);
const NS: &str = "alice";
const NAME: &str = "old";

fn scratch() -> tempfile::TempDir {
    let base = PathBuf::from(env!("CARGO_TARGET_TMPDIR"));
    std::fs::create_dir_all(&base).expect("scratch base");
    tempfile::tempdir_in(base).expect("tempdir")
}

// ── the fixture format: gzip over (path, mode, bytes) records ─────────────────
//
// Not tar: one less dev-dependency for two functions. Every regular file under
// the data directory, its path relative to it, its permission bits, its bytes.

fn pack_dir(root: &Path, out: &Path) {
    fn files(root: &Path, dir: &Path, acc: &mut Vec<PathBuf>) {
        let mut items: Vec<_> = std::fs::read_dir(dir).unwrap().flatten().collect();
        items.sort_by_key(|e| e.file_name());
        for e in items {
            let m = std::fs::symlink_metadata(e.path()).unwrap();
            if m.is_dir() {
                files(root, &e.path(), acc);
            } else if m.is_file() {
                acc.push(e.path().strip_prefix(root).unwrap().to_path_buf());
            }
        }
    }
    let mut all = Vec::new();
    files(root, root, &mut all);
    let mut gz = flate2::write::GzEncoder::new(
        std::fs::File::create(out).unwrap(),
        flate2::Compression::best(),
    );
    for rel in all {
        let path = root.join(&rel);
        let bytes = std::fs::read(&path).unwrap();
        let name = rel.to_string_lossy();
        gz.write_all(&(name.len() as u16).to_le_bytes()).unwrap();
        gz.write_all(name.as_bytes()).unwrap();
        gz.write_all(&std::fs::metadata(&path).unwrap().mode().to_le_bytes())
            .unwrap();
        gz.write_all(&(bytes.len() as u64).to_le_bytes()).unwrap();
        gz.write_all(&bytes).unwrap();
    }
    gz.finish().unwrap();
}

fn unpack_into(archive: &Path, root: &Path) {
    use std::os::unix::fs::PermissionsExt;
    let mut gz = flate2::read::GzDecoder::new(std::fs::File::open(archive).unwrap());
    loop {
        let mut len = [0u8; 2];
        match gz.read_exact(&mut len) {
            Ok(()) => {}
            Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => break,
            Err(e) => panic!("{e}"),
        }
        let mut name = vec![0u8; u16::from_le_bytes(len) as usize];
        gz.read_exact(&mut name).unwrap();
        let mut mode = [0u8; 4];
        gz.read_exact(&mut mode).unwrap();
        let mut size = [0u8; 8];
        gz.read_exact(&mut size).unwrap();
        let mut bytes = vec![0u8; u64::from_le_bytes(size) as usize];
        gz.read_exact(&mut bytes).unwrap();
        let path = root.join(String::from_utf8(name).unwrap());
        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
        std::fs::write(&path, &bytes).unwrap();
        std::fs::set_permissions(
            &path,
            std::fs::Permissions::from_mode(u32::from_le_bytes(mode) & 0o7777),
        )
        .unwrap();
    }
}

// ── a push, the shipping way ──────────────────────────────────────────────────

/// A one-commit pack on top of `parent`: README blob, tree, commit.
fn commit_pack(
    kind: gix_hash::Kind,
    parent: Option<gix_hash::ObjectId>,
    text: &str,
) -> (Vec<u8>, gix_hash::ObjectId) {
    let blob = format!("{text}\n").into_bytes();
    let blob_id = gix_object::compute_hash(kind, gix_object::Kind::Blob, &blob).unwrap();
    let mut tree = b"100644 README\0".to_vec();
    tree.extend_from_slice(blob_id.as_slice());
    let tree_id = gix_object::compute_hash(kind, gix_object::Kind::Tree, &tree).unwrap();
    let parent = parent.map(|p| format!("parent {p}\n")).unwrap_or_default();
    let commit = format!(
        "tree {tree_id}\n{parent}author A <a@example.invalid> 1700000000 +0000\ncommitter A \
         <a@example.invalid> 1700000000 +0000\n\n{text}\n"
    )
    .into_bytes();
    let commit_id = gix_object::compute_hash(kind, gix_object::Kind::Commit, &commit).unwrap();
    let mut pb = PackBuilder::new(kind);
    pb.object(gix_object::Kind::Commit, commit);
    pb.object(gix_object::Kind::Tree, tree);
    pb.object(gix_object::Kind::Blob, blob);
    (pb.build().unwrap().bytes, commit_id)
}

fn push(stores: &ServerStores, path: &RepoRef, text: &str) -> gix_hash::ObjectId {
    let repo = stores.catalog().get(path).unwrap().expect("row");
    let handle = stores.repo_store(&repo).expect("open");
    let kind = handle.hash_kind();
    let git_dir = handle.git_dir().to_path_buf();
    let old = handle
        .serve()
        .refs()
        .unwrap()
        .into_iter()
        .find(|r| r.name == "refs/heads/main")
        .and_then(|r| r.oid)
        .map(|oid| gix_hash::ObjectId::try_from(oid.as_slice()).unwrap());
    let (pack, tip) = commit_pack(kind, old, text);
    let old_hex = old.unwrap_or_else(|| kind.null()).to_string();
    let format = if kind == gix_hash::Kind::Sha1 {
        "sha1"
    } else {
        "sha256"
    };
    let mut line =
        format!("{old_hex} {tip} refs/heads/main\0report-status object-format={format}\n")
            .into_bytes();
    let mut input = format!("{:04x}", line.len() + 4).into_bytes();
    input.append(&mut line);
    input.extend_from_slice(b"0000");
    input.extend_from_slice(&pack);
    let mut reader = std::io::Cursor::new(input);
    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()).unwrap(),
            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 mut out = Vec::new();
    let outcome = ReceivePack::new(handle.serve(), kind)
        .with_config(Config::default())
        .serve(&mut reader, &mut out, &ingest, &AllowAll)
        .expect("serve")
        .expect("a push happened");
    assert!(outcome.unpack.is_ok(), "{:?}", outcome.unpack);
    tip
}

fn main_ref(stores: &ServerStores, path: &RepoRef) -> Vec<u8> {
    let repo = stores.catalog().get(path).unwrap().expect("row");
    let handle = stores.repo_store(&repo).expect("open");
    handle
        .serve()
        .refs()
        .unwrap()
        .into_iter()
        .find(|r| r.name == "refs/heads/main")
        .expect("refs/heads/main")
        .oid
        .expect("a direct ref")
}

fn allocated(path: &Path) -> u64 {
    std::fs::metadata(path).unwrap().blocks() * 512
}

fn tail_of(stores: &ServerStores, path: &RepoRef) -> PathBuf {
    stores
        .repo_dir(path)
        .unwrap()
        .join("gunnar-git.znippy")
        .join("objects.tail")
}

/// Close every store the way `gunnar-server` does at exit.
fn close_all(stores: ServerStores, paths: &[RepoRef]) {
    for p in paths {
        stores.close_repo(p);
    }
    drop(stores);
    gunnar_server::storage::shutdown();
}

/// **Writes the fixture** — run by hand with the build whose layout it records:
/// `GUNNAR_OLD_LAYOUT_OUT=<file> cargo test -p gunnar-server --test
/// old_layout_store -- --ignored write_the_old_layout_fixture`.
#[test]
#[ignore = "writes tests/fixtures/old-layout-store.bin.gz; run by hand with the old build"]
fn write_the_old_layout_fixture() {
    let out =
        PathBuf::from(std::env::var_os("GUNNAR_OLD_LAYOUT_OUT").expect("GUNNAR_OLD_LAYOUT_OUT"));
    let dir = scratch();
    let stores = ServerStores::open(dir.path()).unwrap();
    stores.catalog().create_namespace(NS).unwrap();
    let path = RepoRef::new(NS, NAME).unwrap();
    stores
        .create_repo(NewRepo::new(path.clone()), false)
        .unwrap();
    let tip = push(&stores, &path, "written by the old layout");
    std::thread::sleep(std::time::Duration::from_secs(2));
    let tail = tail_of(&stores, &path);
    close_all(stores, &[path]);
    println!(
        "old layout: tip {tip}, objects.tail {} B apparent, {} B allocated",
        std::fs::metadata(&tail).unwrap().len(),
        allocated(&tail)
    );
    pack_dir(dir.path(), &out);
}

#[test]
fn a_store_written_by_the_old_layout_opens_and_works() {
    let dir = scratch();
    unpack_into(Path::new(FIXTURE), dir.path());
    let path = RepoRef::new(NS, NAME).unwrap();

    let stores = ServerStores::open(dir.path()).expect("the old data directory opens");
    let tail = tail_of(&stores, &path);
    let old_tail = allocated(&tail);
    assert!(
        old_tail > 1_000_000,
        "fixture: the old layout's redb 2 tail allocates {old_tail} B (the cost this upgrade removes)"
    );

    // What the old build pushed reads back, commit, tree and blob.
    let first = main_ref(&stores, &path);
    let repo = stores
        .catalog()
        .get(&path)
        .unwrap()
        .expect("the catalog row survives");
    let handle = stores.repo_store(&repo).expect("the old store opens");
    let (kind, body) = handle
        .serve()
        .read(&first)
        .expect("read")
        .expect("the old commit is there");
    assert_eq!(format!("{kind:?}"), "Commit");
    let text = String::from_utf8_lossy(&body);
    assert!(text.contains("written by the old layout"), "{text}");
    let tree =
        gix_hash::ObjectId::from_hex(&text.lines().next().unwrap().as_bytes()["tree ".len()..])
            .unwrap();
    assert!(
        handle.serve().read(tree.as_bytes()).unwrap().is_some(),
        "the old tree"
    );
    drop(handle);

    // It takes a push on top.
    let second = push(&stores, &path, "written by the new layout");
    assert_eq!(main_ref(&stores, &path), second.as_bytes());
    std::thread::sleep(std::time::Duration::from_secs(1));
    close_all(stores, std::slice::from_ref(&path));

    let new_tail = allocated(&tail);
    assert!(
        new_tail <= 100 * 1024,
        "the migrated tail allocates {new_tail} B (old: {old_tail} B)"
    );
    assert!(
        !tail.with_file_name("objects.tail.redb4-migrating").exists(),
        "no staging file is left"
    );

    // And again, from cold: both commits, the second on top of the first.
    let stores = ServerStores::open(dir.path()).expect("reopen");
    assert_eq!(main_ref(&stores, &path), second.as_bytes());
    let repo = stores.catalog().get(&path).unwrap().unwrap();
    let handle = stores.repo_store(&repo).unwrap();
    let (_, body) = handle
        .serve()
        .read(second.as_bytes())
        .unwrap()
        .expect("the new commit");
    let parent = format!(
        "parent {}",
        gix_hash::ObjectId::try_from(first.as_slice()).unwrap()
    );
    assert!(String::from_utf8_lossy(&body).contains(&parent));
    assert!(
        handle.serve().read(&first).unwrap().is_some(),
        "the old commit, after the migration"
    );
}