Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

crates/gunnar-core/tests/catalog.rs

//! The repository catalog: namespaces, both hash kinds, and persistence over a
//! real control database on disk rather than only in memory.

mod common;

use std::sync::Arc;

use common::Fixture;
use gunnar_core::{AuthTable, HashKind, NewRepo, RepoCatalog, RepoRef, Visibility, DEFAULT_BRANCH};
// `point_head_at` writes through a repository's OWN git store, which did not
// move: the certificate-identity plan moved the two control DOCUMENTS, not a repository's refs.
use gunnar_store::git_storage_trait::GitServe;

#[test]
fn a_repository_needs_a_namespace_that_exists() {
    let fx = Fixture::new();
    let path = RepoRef::new("acme", "widget").expect("legal");

    // A typo in a push URL must not silently become a tenant.
    let err = fx
        .catalog
        .create(NewRepo::new(path.clone()))
        .expect_err("no namespace yet");
    assert!(format!("{err}").contains("no such namespace"), "{err}");

    fx.catalog.create_namespace("acme").expect("create");
    let repo = fx
        .catalog
        .create(NewRepo::new(path.clone()))
        .expect("create");
    assert_eq!(repo.namespace, "acme");
    assert_eq!(repo.name, "widget");
    assert_eq!(repo.default_branch, DEFAULT_BRANCH);
    assert_eq!(repo.visibility, Visibility::Private);
    assert_eq!(repo.hash_kind, HashKind::Sha256, "sha256 is the default");

    // Twice is an error, not a silent overwrite.
    assert!(fx.catalog.create(NewRepo::new(path.clone())).is_err());
    assert_eq!(fx.catalog.list(None).expect("list").len(), 1);
}

/// Both hash kinds, side by side. sha256 is not inferred from a green sha1
/// (the verification rule).
#[test]
fn both_hash_kinds_are_stored_and_kept_apart() {
    let fx = Fixture::new();
    let old = fx.repo_with("acme", "legacy", HashKind::Sha1, Visibility::Private);
    let new = fx.repo_with("acme", "modern", HashKind::Sha256, Visibility::Private);

    assert_eq!(old.hash_kind, HashKind::Sha1);
    assert_eq!(new.hash_kind, HashKind::Sha256);
    assert_eq!(
        gix_hash::Kind::from(old.hash_kind),
        gix_hash::Kind::Sha1,
        "the conversion the object store is opened with must not collapse the two"
    );
    assert_eq!(gix_hash::Kind::from(new.hash_kind), gix_hash::Kind::Sha256);
    assert_eq!(old.hash_kind.to_string(), "sha1");
    assert_eq!(new.hash_kind.to_string(), "sha256");

    // …and they survive a reload as themselves.
    assert_eq!(
        fx.catalog.open(&old.path()).expect("open").hash_kind,
        HashKind::Sha1
    );
    assert_eq!(
        fx.catalog.open(&new.path()).expect("open").hash_kind,
        HashKind::Sha256
    );
}

#[test]
fn open_list_and_delete() {
    let fx = Fixture::new();
    fx.repo("acme", "one");
    fx.repo("acme", "two");
    fx.repo("other", "three");

    assert_eq!(fx.catalog.list(None).expect("list").len(), 3);
    assert_eq!(fx.catalog.list(Some("acme")).expect("list").len(), 2);
    assert_eq!(fx.catalog.list(Some("nope")).expect("list").len(), 0);
    assert_eq!(fx.catalog.counts().expect("counts"), (2, 3));

    let path = RepoRef::new("acme", "one").expect("legal");
    assert!(fx.catalog.contains(&path).expect("contains"));
    let gone = fx.catalog.delete(&path).expect("delete");
    assert_eq!(gone.name, "one");
    assert!(!fx.catalog.contains(&path).expect("contains"));
    assert!(fx.catalog.open(&path).is_err());
    assert!(
        fx.catalog.delete(&path).is_err(),
        "deleting twice is an error"
    );
    assert_eq!(fx.catalog.list(None).expect("list").len(), 2);
}

/// A path that parses is not a repository that exists. The SSH layer has to be
/// able to tell those apart, and `open` is where it does.
#[test]
fn a_legal_path_that_is_not_in_the_catalog_is_not_a_repository() {
    let fx = Fixture::new();
    fx.repo("acme", "widget");
    // Already unquoted: `gunnar_ssh::command` takes git's single quotes off
    // before the sandbox ever sees the argument.
    let path = gunnar_core::resolve_repo_path("acme/nothing.git").expect("legal path");
    let err = fx.catalog.open(&path).expect_err("not catalogued");
    assert!(format!("{err}").contains("no such repository"), "{err}");
    assert!(!err.is_path_rejection(), "a miss is not a path rejection");
}

#[test]
fn a_namespace_is_not_deleted_out_from_under_its_repositories() {
    let fx = Fixture::new();
    fx.repo("acme", "one");
    fx.repo("acme", "two");

    let err = fx
        .catalog
        .delete_namespace("acme", false)
        .expect_err("not empty");
    assert!(format!("{err}").contains("still holds 2"), "{err}");
    assert_eq!(fx.catalog.list(None).expect("list").len(), 2);

    let taken = fx.catalog.delete_namespace("acme", true).expect("delete");
    assert_eq!(taken.len(), 2);
    assert!(fx.catalog.list(None).expect("list").is_empty());
    assert!(fx.catalog.namespace("acme").expect("read").is_none());
    assert!(fx.catalog.delete_namespace("acme", true).is_err());
}

#[test]
fn namespaces_are_validated_like_every_other_path_component() {
    let fx = Fixture::new();
    assert!(fx.catalog.create_namespace("acme").is_ok());
    assert!(fx.catalog.create_namespace("team/sub").is_ok());
    assert!(
        fx.catalog.create_namespace("acme").is_err(),
        "no duplicates"
    );
    for bad in [
        "", "..", "../etc", "a/../b", ".hidden", "-flag", "ns\0", "café", "a/b/c/d",
    ] {
        assert!(
            fx.catalog.create_namespace(bad).is_err(),
            "{bad:?} must not become a namespace"
        );
    }
    assert_eq!(fx.catalog.namespaces().expect("list").len(), 2);
}

#[test]
fn the_default_branch_must_be_a_full_ref_name() {
    let fx = Fixture::new();
    fx.catalog.create_namespace("acme").expect("ns");
    let path = RepoRef::new("acme", "widget").expect("legal");

    for bad in [
        "main",
        "refs/heads/",
        "refs/tags/v1",
        "refs/heads/..",
        "refs/heads/a b",
    ] {
        assert!(
            fx.catalog
                .create(NewRepo::new(path.clone()).default_branch(bad))
                .is_err(),
            "{bad:?} must not be a default branch"
        );
    }
    let repo = fx
        .catalog
        .create(NewRepo::new(path.clone()).default_branch("refs/heads/trunk"))
        .expect("create");
    assert_eq!(repo.default_branch, "refs/heads/trunk");

    let moved = fx
        .catalog
        .set_default_branch(&path, "refs/heads/release")
        .expect("set");
    assert_eq!(moved.default_branch, "refs/heads/release");
    assert!(fx.catalog.set_default_branch(&path, "release").is_err());
    assert_eq!(
        fx.catalog.open(&path).expect("open").default_branch,
        "refs/heads/release"
    );
}

/// **This round trip is exactly the assertion that let the 2026-08-06 defect
/// ship**, and it is left directly above this test on purpose.
///
/// `set_default_branch` wrote the catalog, `open` read the same value back, and
/// the test above went green — while the *repository's* `HEAD`, which is what
/// the reference advertisement resolves, still named the branch `init_bare` had
/// written and nobody could clone the repository. The catalog was never wrong.
/// It was only ever half the answer.
///
/// So this test is about the other half: the projection, and specifically about
/// it reporting **whether it moved**. A projection that always claims to have
/// moved logs a repair on every open of every healthy repository, and one that
/// never claims to logs nothing on the open that actually fixed something —
/// either way the log stops being evidence.
#[test]
fn head_is_a_projection_of_the_catalogs_default_branch_and_says_when_it_moved() {
    // `MemGitServe` is the conformance suite's control backend — the only
    // in-repository `GitServe` there is, both real ones living in znippy and
    // edda. It is used here for the same reason `MemRefStore` was: this test is
    // about the projection, not about a backend.
    //
    // The read goes through `head()` and not through `refs()`, because the `GitServe` HEAD ruling
    // settled that `HEAD` is **not** a row: reading it out of the ref stream
    // would be asserting the defect this suite now catches.
    let git = gunnar_store::mem::MemGitServe::new(gix_hash::Kind::Sha256);
    let head = |git: &gunnar_store::mem::MemGitServe| -> Option<String> {
        git.head().expect("head").and_then(|row| row.symref_target)
    };

    // A store with no HEAD at all: the projection has to write one.
    assert_eq!(head(&git), None);
    assert!(
        gunnar_core::point_head_at(&git, "refs/heads/master").expect("point"),
        "writing HEAD where there was none is a move and must be reported as one"
    );
    assert_eq!(head(&git).as_deref(), Some("refs/heads/master"));

    // Already correct: no write, and it says so.
    assert!(
        !gunnar_core::point_head_at(&git, "refs/heads/master").expect("point"),
        "HEAD already named the default branch, so nothing moved and nothing may claim to"
    );

    // Moved somewhere else: a move again.
    assert!(gunnar_core::point_head_at(&git, "refs/heads/trunk").expect("point"));
    assert_eq!(head(&git).as_deref(), Some("refs/heads/trunk"));

    // The same validation the catalog applies, so the two cannot diverge on
    // what a legal default branch is.
    for bad in ["trunk", "refs/tags/v1", "refs/heads/..", ""] {
        assert!(
            gunnar_core::point_head_at(&git, bad).is_err(),
            "{bad:?} must not be projectable onto HEAD"
        );
    }
    assert_eq!(
        head(&git).as_deref(),
        Some("refs/heads/trunk"),
        "a refused name must leave HEAD where it was"
    );
}

/// The catalog is a blob plus a pointer, so a second handle over the same
/// store sees the same state, which is what makes it storage rather than a
/// cache.
#[test]
fn a_second_handle_over_the_same_stores_sees_the_same_catalog() {
    let fx = Fixture::new();
    fx.repo("acme", "widget");

    let second = RepoCatalog::new(Arc::clone(&fx.store)).expect("open");
    assert_eq!(second.list(None).expect("list").len(), 1);
    second
        .create(NewRepo::new(RepoRef::new("acme", "other").expect("legal")))
        .expect("create through the second handle");
    assert_eq!(fx.catalog.list(None).expect("list").len(), 2);

    // The catalog and the identity table are separate documents under separate
    // names, and must not overwrite each other.
    assert_ne!(fx.catalog.ref_name(), fx.auth.ref_name());
    assert!(fx
        .store
        .read_doc(fx.catalog.ref_name())
        .expect("read")
        .is_some());
}

/// Over a **real control database on a real disk**, not only the in-memory
/// backend: everything is closed, the file is reopened, and the catalog and the
/// identity table are still there.
///
/// This replaces the Arrow-IPC-ref-log restart test that stood here before the
/// certificate-identity plan moved these two documents out of the git object
/// store. The claim is the same one and it is the one that matters — *a restart
/// does not lose the control plane* — asserted against the storage that now
/// holds it.
#[test]
fn the_catalog_survives_a_restart_of_the_control_database() {
    let dir = tempfile::tempdir().expect("temp dir");
    let path = dir.path().join("control.redb");

    {
        let store = gunnar_core::open_control_store(&path).expect("open");
        let catalog = RepoCatalog::new(Arc::clone(&store)).expect("catalog");
        let auth = AuthTable::new(store).expect("auth");
        catalog.create_namespace("acme").expect("ns");
        catalog
            .create(
                NewRepo::new(RepoRef::new("acme", "widget").expect("legal"))
                    .hash_kind(HashKind::Sha1)
                    .visibility(Visibility::Public),
            )
            .expect("create");
        auth.add_principal(
            gunnar_core::Principal::new("ada", "Ada", gunnar_core::PrincipalKind::User)
                .expect("id"),
        )
        .expect("add");
    }

    // Reopen the file: the restart.
    let store = gunnar_core::open_control_store(&path).expect("reopen");
    let catalog = RepoCatalog::new(Arc::clone(&store)).expect("catalog");
    let auth = AuthTable::new(Arc::clone(&store)).expect("auth");

    let repos = catalog.list(None).expect("list");
    assert_eq!(repos.len(), 1, "the catalog did not survive the restart");
    assert_eq!(repos[0].name, "widget");
    assert_eq!(repos[0].hash_kind, HashKind::Sha1);
    assert_eq!(repos[0].visibility, Visibility::Public);
    assert_eq!(catalog.namespaces().expect("list").len(), 1);
    assert_eq!(auth.principals().expect("list").len(), 1);

    // The control plane's audit trail survived it too, and it is what a ref log used to give
    // away for free: three changes above the namespace, in order.
    let history = auth.history().expect("history");
    assert_eq!(
        history.len(),
        1,
        "one principal was added, so the identity table has exactly one version"
    );
    assert_eq!(
        catalog.history().expect("history").len(),
        2,
        "a namespace and a repository are two changes to the catalog"
    );
}

/// An empty catalog and a broken one must not look the same. A storage fault
/// that reads as "no repositories" is the failure mode that silently opens a
/// server up (see `Error::CorruptDocument`).
#[test]
fn a_corrupt_catalog_document_is_an_error_not_an_empty_catalog() {
    let fx = Fixture::new();
    fx.repo("acme", "widget");

    // Point the catalog document at something that is not a catalog, through
    // the real compare-and-swap: a write another writer could have made.
    let junk = fx.store.put_blob(b"not json at all").expect("write");
    let current = fx.store.read_doc(fx.catalog.ref_name()).expect("read");
    assert!(fx
        .store
        .commit_doc(fx.catalog.ref_name(), current, junk)
        .expect("commit"));

    let err = fx.catalog.list(None).expect_err("must not read as empty");
    assert!(format!("{err}").contains("unusable"), "{err}");
}

/// ★ **An account's explode quota round-trips through the control document,
/// and "unset" is not the same answer as "unlimited".**
///
/// The quota is what a plan buys (2026-08-30): a bigger exploded read
/// cache means more objects are already inflated and reads are fast; a smaller
/// one means more reads re-inflate their own delta chain out of the pack.
/// Three states have to survive a reload and stay distinct, because two of
/// them look alike in every spelling that uses a magic value:
///
/// | stored | means |
/// |---|---|
/// | `None` | take the server's default — `min(10 % of /data, 8 GiB)` |
/// | `Some(0)` | **unlimited**, decided by an admin |
/// | `Some(n)` | this many bytes |
///
/// A namespace written before this field existed must load as `None` and not
/// as `Some(0)`, or every account on an upgraded appliance silently becomes
/// unlimited — which is the shape of the bug this asserts against.
///
/// Seen RED by making `explode_budget_bytes` a `u64` with `0` for "unset": the
/// unlimited row and the default row become the same value and the third
/// assertion below cannot be written at all.
#[test]
fn an_accounts_explode_quota_survives_a_reload_and_unset_is_not_unlimited() {
    let fx = Fixture::new();
    fx.catalog.create_namespace("acme").expect("create");

    // A fresh namespace is on the server's default, not on a number.
    assert_eq!(
        fx.catalog.namespace_explode_budget("acme").expect("read"),
        None,
        "a new account was born with a hard-coded quota instead of the server default"
    );

    let set = fx
        .catalog
        .set_namespace_explode_budget("acme", Some(64 << 30))
        .expect("set");
    assert_eq!(set.explode_budget_bytes, Some(64 << 30));
    assert_eq!(
        fx.catalog.namespace_explode_budget("acme").expect("read"),
        Some(64 << 30),
        "the quota did not survive the write"
    );
    // And it is on the row every lister sees, not only on the one returned.
    let listed = fx
        .catalog
        .namespaces()
        .expect("list")
        .into_iter()
        .find(|ns| ns.name == "acme")
        .expect("acme is listed");
    assert_eq!(listed.explode_budget_bytes, Some(64 << 30));

    // Unlimited is a decision.
    fx.catalog
        .set_namespace_explode_budget("acme", Some(0))
        .expect("set");
    assert_eq!(
        fx.catalog.namespace_explode_budget("acme").expect("read"),
        Some(0),
        "`unlimited` was stored as `unset`"
    );

    // And back to the default, which is a different answer again.
    fx.catalog
        .set_namespace_explode_budget("acme", None)
        .expect("clear");
    assert_eq!(
        fx.catalog.namespace_explode_budget("acme").expect("read"),
        None,
        "clearing a quota left the account unlimited instead of on the default"
    );

    // A namespace that is not there is refused BY NAME rather than silently
    // creating one or answering "the default".
    let err = fx
        .catalog
        .set_namespace_explode_budget("nobody", Some(1 << 30))
        .expect_err("no such namespace");
    assert!(format!("{err}").contains("no such namespace"), "{err}");
}