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/src/catalog.rs

//! The repository catalog: namespaces and repositories, keyed by
//! `<namespace>/<repo>`.
//!
//! Namespaces are first-class from day one (multi-tenancy): every path a client
//! sends has one, every grant can scope to one, and a repository cannot exist
//! outside one. Adding that later would have meant migrating every stored path
//! and every ACL row.
//!
//! The catalog stores **metadata only**. Creating a repository here does not
//! create its object or ref store, and deleting it does not delete them. That
//! belongs to whoever owns the backend (the storage seam), which on an appliance is one
//! `.znippy` per repository and on a normal OS is a directory. Keeping the two
//! apart is what lets `gunnar gc` and a future `repack()` run without the
//! catalog knowing anything about storage layout.

use std::sync::Arc;

use gunnar_store::git_storage_trait::GitServe;

use crate::control::ControlStore;
use crate::doc::{impl_doc, DocRef, ShardedMap};
use crate::error::{Error, Result};
use crate::model::{check_branch, HashKind, Namespace, Repo, Visibility, DEFAULT_BRANCH};
use crate::sandbox::{check_component, RepoRef};

/// **The name the catalog document is stored under** in the control store.
/// See [`crate::AUTH_REF`] for why it still looks like a reference name.
pub const CATALOG_REF: &str = "refs/gunnar/catalog";

/// The catalog as it is stored.
///
/// Both maps are sharded (`doc.rs`). Inline, `repos` held every repository on
/// the server in one blob, so `git repo create` re-serialised and re-hashed the
/// whole catalog and every SSH connection parsed it to answer one key probe.
/// Sharded, a create rewrites one leaf and the two nodes above it, and
/// a lookup reads one leaf (none at all, once it is cached).
#[derive(Default, Clone, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct CatalogDoc {
    /// Keyed by name so the encoding is deterministic.
    #[serde(default)]
    namespaces: ShardedMap<Namespace>,
    /// Keyed by `<namespace>/<repo>`.
    #[serde(default)]
    repos: ShardedMap<Repo>,
}

impl_doc!(CatalogDoc { namespaces, repos });

/// What a new repository is created with.
///
/// Server defaults are applied here rather than by the caller, so the gRPC
/// service, the CLI and a test all get the same repository (one implementation).
#[derive(Clone, Debug)]
pub struct NewRepo {
    /// Where it lives.
    pub path: RepoRef,
    /// sha1 or sha256. Defaults to sha256.
    pub hash_kind: HashKind,
    /// Full ref name. `None` means [`DEFAULT_BRANCH`].
    pub default_branch: Option<String>,
    /// Defaults to [`Visibility::Private`].
    pub visibility: Visibility,
    /// Free text, for UIs.
    pub description: String,
}

impl NewRepo {
    /// A private sha256 repository at `path`, with the default branch.
    pub fn new(path: RepoRef) -> Self {
        NewRepo {
            path,
            hash_kind: HashKind::Sha256,
            default_branch: None,
            visibility: Visibility::Private,
            description: String::new(),
        }
    }

    /// Set the hash kind. sha1 repositories are fully supported.
    pub fn hash_kind(mut self, kind: HashKind) -> Self {
        self.hash_kind = kind;
        self
    }

    /// Set the visibility.
    pub fn visibility(mut self, visibility: Visibility) -> Self {
        self.visibility = visibility;
        self
    }

    /// Set the default branch, as a full `refs/heads/...` name.
    pub fn default_branch(mut self, name: impl Into<String>) -> Self {
        self.default_branch = Some(name.into());
        self
    }

    /// The [`Repo`] this spec would create, **without creating it**.
    ///
    /// The one place a `NewRepo` becomes a `Repo`: [`RepoCatalog::create`]
    /// writes exactly this value, and the first-repository door carries it
    /// through a push's advertisement *before* the catalog has been touched, so
    /// a client that takes one look at the advertised hash and hangs up (a sha1
    /// working tree against the sha256 default) leaves nothing behind. A
    /// door that wrote the catalog first was the 2026-08-30 ghost-repository
    /// bug: `repo get` succeeded for a repository whose push was refused.
    pub fn prospect(&self) -> Result<Repo> {
        let branch = match &self.default_branch {
            Some(name) => check_branch(name)?,
            None => DEFAULT_BRANCH.to_owned(),
        };
        Ok(Repo {
            namespace: self.path.namespace().to_owned(),
            name: self.path.name().to_owned(),
            hash_kind: self.hash_kind,
            default_branch: branch,
            visibility: self.visibility,
            created: crate::model::now_ms(),
            description: self.description.clone(),
            encrypted: false,
        })
    }
}

/// Create, open, list and delete repositories.
///
/// Cheap to clone; every clone reads and writes the same document.
#[derive(Clone, Debug)]
pub struct RepoCatalog {
    doc: DocRef<CatalogDoc>,
}

impl RepoCatalog {
    /// Open the catalog held in `store` (the certificate-identity plan).
    ///
    /// This is the *control* store: the server's own, not a repository's.
    pub fn new(store: Arc<dyn ControlStore>) -> Result<Self> {
        Ok(RepoCatalog {
            doc: DocRef::new(store, CATALOG_REF)?,
        })
    }

    /// The name the catalog is stored under.
    pub fn ref_name(&self) -> &str {
        self.doc.ref_name()
    }

    /// Every version this catalog has ever had, oldest first (the certificate-identity plan). See
    /// [`crate::AuthTable::history`].
    pub fn history(&self) -> Result<Vec<crate::control::DocVersion>> {
        self.doc.history()
    }

    // ── namespaces ─────────────────────────────────────────────────────────

    /// Create a namespace.
    ///
    /// Nested names (`team/sub`) are allowed and every level is validated by
    /// the sandbox's component rules, so a namespace can never be a path
    /// escape.
    pub fn create_namespace(&self, name: &str) -> Result<Namespace> {
        for level in name.split('/') {
            check_component(name, level)?;
        }
        if name.split('/').count() >= crate::sandbox::MAX_COMPONENTS {
            // One component has to be left for the repository name.
            return Err(Error::InvalidPath {
                input: name.escape_debug().to_string(),
                reason: crate::sandbox::PathReject::TooManyComponents,
            });
        }
        let namespace = Namespace {
            name: name.to_owned(),
            // A new account takes the server's default until an admin sets a
            // plan. `None` and not a copy of the default: copying it would
            // freeze today's default into every namespace ever created, and a
            // later change to the appliance's disk would not reach them.
            explode_budget_bytes: None,
            // Private, and its events too, until its account admin says
            // otherwise (`RepoCatalog::set_namespace_public`,
            // `RepoCatalog::set_namespace_events_public`).
            public: false,
            events_public: false,
        };
        self.doc.update(|doc| {
            if doc.namespaces.contains_key(name)? {
                return Err(Error::NamespaceExists(name.to_owned()));
            }
            doc.namespaces.insert(name.to_owned(), namespace.clone())?;
            Ok(namespace.clone())
        })
    }

    /// **Set (or clear) an account's exploded-cache quota.**
    ///
    /// `None` puts the namespace back on the server's default; `Some(0)` is
    /// unlimited and is a decision rather than an absence. The quota reaches a
    /// repository the next time its store is opened, and every store already
    /// open under this namespace immediately — see
    /// `ServerStores::apply_explode_quota`.
    ///
    /// It cannot fail a read and it cannot lose an object: the cache it bounds
    /// is re-derivable from the packs, which it does not bound. Lowering a
    /// quota below what a repository already holds evicts; it never refuses.
    pub fn set_namespace_explode_budget(
        &self,
        name: &str,
        bytes: Option<u64>,
    ) -> Result<Namespace> {
        self.doc.update(|doc| {
            let Some(mut ns) = doc.namespaces.get(name)? else {
                return Err(Error::NoSuchNamespace(name.to_owned()));
            };
            ns.explode_budget_bytes = bytes;
            doc.namespaces.insert(name.to_owned(), ns.clone())?;
            Ok(ns)
        })
    }

    /// This namespace's quota, or `None` for "the server's default". A missing
    /// namespace is `None` too: a repository whose namespace row is gone is a
    /// broken catalog, and answering "the default" is the safe reading of it.
    pub fn namespace_explode_budget(&self, name: &str) -> Result<Option<u64>> {
        Ok(self
            .doc
            .load()?
            .namespaces
            .get(name)?
            .and_then(|ns| ns.explode_budget_bytes))
    }

    /// **Make a namespace public, or private again** ([`Namespace::public`]).
    ///
    /// Only the stored fact: who may change it (the account admin) is the gRPC
    /// layer's question (`Repos.SetNamespaceSettings`). It touches no
    /// repository — a private repository stays private in a public namespace.
    pub fn set_namespace_public(&self, name: &str, public: bool) -> Result<Namespace> {
        self.update_namespace(name, |ns| ns.public = public)
    }

    /// **Make an account's emitted events public, or private again**
    /// ([`Namespace::events_public`]). The stored fact only, as
    /// [`RepoCatalog::set_namespace_public`].
    pub fn set_namespace_events_public(&self, name: &str, public: bool) -> Result<Namespace> {
        self.update_namespace(name, |ns| ns.events_public = public)
    }

    /// One namespace row, changed in one compare-and-swap.
    fn update_namespace(&self, name: &str, change: impl Fn(&mut Namespace)) -> Result<Namespace> {
        self.doc.update(|doc| {
            let Some(mut ns) = doc.namespaces.get(name)? else {
                return Err(Error::NoSuchNamespace(name.to_owned()));
            };
            change(&mut ns);
            doc.namespaces.insert(name.to_owned(), ns.clone())?;
            Ok(ns)
        })
    }

    /// **The catalog's version**: changes exactly when any namespace or
    /// repository row does. One pointer read — what lets a reader that keeps
    /// its own per-namespace index of the catalog (the server's usage ledger)
    /// rebuild it only when something changed, instead of listing every
    /// repository on every read.
    pub fn version(&self) -> Result<Option<crate::control::BlobId>> {
        self.doc.version()
    }

    /// Every namespace, by name. **O(namespaces)**, the listing path.
    pub fn namespaces(&self) -> Result<Vec<Namespace>> {
        self.doc.load()?.namespaces.values()
    }

    /// One namespace, if it exists.
    pub fn namespace(&self, name: &str) -> Result<Option<Namespace>> {
        self.doc.load()?.namespaces.get(name)
    }

    /// Delete a namespace, and return the repositories that went with it.
    ///
    /// Refuses a namespace that still holds repositories unless `delete_repos`.
    /// Deleting a tenant's repositories has to be asked for explicitly.
    pub fn delete_namespace(&self, name: &str, delete_repos: bool) -> Result<Vec<Repo>> {
        self.doc.update(|doc| {
            if !doc.namespaces.contains_key(name)? {
                return Err(Error::NoSuchNamespace(name.to_owned()));
            }
            // O(repositories on the server): a tenant's repositories are not
            // indexed apart from every other tenant's, because the connection
            // path looks a repository up by its full key and never by namespace.
            let doomed: Vec<Repo> = doc
                .repos
                .values()?
                .into_iter()
                .filter(|repo| repo.namespace == name)
                .collect();
            if !doomed.is_empty() && !delete_repos {
                return Err(Error::NamespaceNotEmpty {
                    name: name.to_owned(),
                    repos: doomed.len(),
                });
            }
            for repo in &doomed {
                doc.repos.remove(&repo.key())?;
            }
            doc.namespaces.remove(name)?;
            Ok(doomed)
        })
    }

    // ── repositories ───────────────────────────────────────────────────────

    /// Create a repository.
    ///
    /// The namespace must already exist. A repository is never allowed to
    /// conjure one, because then a typo in a push URL would silently become a
    /// tenant.
    pub fn create(&self, spec: NewRepo) -> Result<Repo> {
        let repo = spec.prospect()?;
        let key = spec.path.key();
        self.doc.update(|doc| {
            if !doc.namespaces.contains_key(repo.namespace.as_str())? {
                return Err(Error::NoSuchNamespace(repo.namespace.clone()));
            }
            if doc.repos.contains_key(&key)? {
                return Err(Error::RepoExists(spec.path.clone()));
            }
            doc.repos.insert(key.clone(), repo.clone())?;
            Ok(repo.clone())
        })
    }

    /// Look a repository up, failing if it is not there.
    ///
    /// This is what the SSH layer calls after the sandbox has produced a
    /// [`RepoRef`]: a path that parses is not a repository that exists.
    pub fn open(&self, path: &RepoRef) -> Result<Repo> {
        self.get(path)?
            .ok_or_else(|| Error::NoSuchRepo(path.clone()))
    }

    /// Look a repository up.
    ///
    /// **This is the connection path**. `Control::resolve` calls it for every
    /// SSH session. One shard probe, and no object read at all once the catalog
    /// has been read since it last changed.
    pub fn get(&self, path: &RepoRef) -> Result<Option<Repo>> {
        self.doc.load()?.repos.get(&path.key())
    }

    /// **Many rows, one read of the catalog** (round 5.3.1): `get` for each
    /// path, the document's pointer read once rather than once per row — a
    /// listing's access check asks about every row it lists.
    pub fn get_many(&self, paths: &[RepoRef]) -> Result<Vec<Option<Repo>>> {
        let doc = self.doc.load()?;
        paths.iter().map(|p| doc.repos.get(&p.key())).collect()
    }

    /// Is this repository in the catalog?
    pub fn contains(&self, path: &RepoRef) -> Result<bool> {
        self.doc.load()?.repos.contains_key(&path.key())
    }

    /// Every repository, or every repository in one namespace, ordered by key.
    ///
    /// **O(repositories)** either way; see [`RepoCatalog::delete_namespace`].
    pub fn list(&self, namespace: Option<&str>) -> Result<Vec<Repo>> {
        let doc = self.doc.load()?;
        Ok(doc
            .repos
            .values()?
            .into_iter()
            .filter(|repo| namespace.is_none_or(|ns| repo.namespace == ns))
            .collect())
    }

    /// Remove a repository's metadata and hand it back.
    ///
    /// The caller owns the storage: whatever backend held its objects and refs
    /// is still there afterwards and must be dealt with separately.
    pub fn delete(&self, path: &RepoRef) -> Result<Repo> {
        let key = path.key();
        self.doc.update(|doc| {
            doc.repos
                .remove(&key)?
                .ok_or_else(|| Error::NoSuchRepo(path.clone()))
        })
    }

    /// Record another default branch for a repository.
    ///
    /// This writes the **catalog** and nothing else. The repository's own
    /// `HEAD` is a projection of this value and is moved by
    /// [`point_head_at`], which whoever owns the repository's stores calls
    /// with the `Repo` this hands back. Doing it here is not possible: the
    /// catalog holds metadata only and has never had a repository's ref store
    /// (see this module's opening comment).
    pub fn set_default_branch(&self, path: &RepoRef, branch: &str) -> Result<Repo> {
        let branch = check_branch(branch)?;
        let key = path.key();
        self.doc.update(|doc| {
            let mut repo = doc
                .repos
                .get(&key)?
                .ok_or_else(|| Error::NoSuchRepo(path.clone()))?;
            repo.default_branch = branch.clone();
            doc.repos.insert(key.clone(), repo.clone())?;
            Ok(repo)
        })
    }

    /// Change a repository's visibility.
    pub fn set_visibility(&self, path: &RepoRef, visibility: Visibility) -> Result<Repo> {
        let key = path.key();
        self.doc.update(|doc| {
            let mut repo = doc
                .repos
                .get(&key)?
                .ok_or_else(|| Error::NoSuchRepo(path.clone()))?;
            repo.visibility = visibility;
            doc.repos.insert(key.clone(), repo.clone())?;
            Ok(repo)
        })
    }

    /// Turn a repository's "encrypted" flag on or off (`Repos.SetEncrypted`;
    /// the account admin's decision, checked by the caller).
    pub fn set_encrypted(&self, path: &RepoRef, encrypted: bool) -> Result<Repo> {
        let key = path.key();
        self.doc.update(|doc| {
            let mut repo = doc
                .repos
                .get(&key)?
                .ok_or_else(|| Error::NoSuchRepo(path.clone()))?;
            repo.encrypted = encrypted;
            doc.repos.insert(key.clone(), repo.clone())?;
            Ok(repo)
        })
    }

    /// How many namespaces and repositories there are, for `Admin.Stats`.
    pub fn counts(&self) -> Result<(usize, usize)> {
        let doc = self.doc.load()?;
        Ok((doc.namespaces.len(), doc.repos.len()))
    }
}

/// Make a repository's `HEAD` name the default branch its catalog entry
/// carries, and say whether it had to move.
///
/// # The bug this exists to make impossible
///
/// The catalog is the **authority** for a repository's default branch. A
/// repository's own `HEAD` is a **projection** of that value, and this is the
/// only writer of the projection (one implementation).
///
/// Until 2026-08-06 there was no projection at all, so the two disagreed the
/// moment anyone moved the authority. `FsStore::open_or_init` wrote
/// `ref: refs/heads/main` into a new repository and nothing ever wrote it
/// again; `Repos.SetDefaultBranch` wrote the catalog and nothing else. So
/// `set-default-branch master` answered `refs/heads/master`, `repo get` read
/// `refs/heads/master` back — and the reference advertisement, which reads the
/// repository's `HEAD` and not the catalog, still resolved
/// `refs/heads/main`. That branch had never been created, so `HEAD` had no
/// object, so it was **dropped from the advertisement entirely** and every
/// `master`-default donor (`foot`, `ripgrep`, `mdBook`) pushed cleanly into a
/// repository nobody could clone back. MEASURED that day: `ls-remote` returned
/// one line, `refs/heads/master`, and no `HEAD` line at all.
///
/// # Why it takes a `&dyn GitServe` and not a `&RepoCatalog`
///
/// The catalog stores metadata only and has never held a repository's stores;
/// the caller that opens those is the one that can hand both halves over. That
/// caller is `ServerStores::repo_store`, which runs on create, on
/// `set-default-branch` and on the first transfer after a restart — so an
/// already-broken repository on disk is repaired the next time it is opened,
/// with no migration step.
///
/// A symbolic `HEAD` is written whether or not the branch exists. An unborn
/// `HEAD` is legal and is exactly what a freshly created, never-pushed
/// repository has; what is not legal is a `HEAD` naming a branch the owner
/// never asked for.
///
/// # ⚠ The contract point this depended on is SETTLED — 2026-08-10, the `GitServe` HEAD ruling
///
/// This used to be `RefStore::head()` / `set_head()`, and when that pair died it
/// was rewritten against `GitOps::refs()` / `put_refs()` with a warning attached:
/// *whether `refs()` includes a row for `HEAD` is an open question … both cannot
/// be right*. It is answered. **`HEAD` is not a row.** Every backend excludes
/// pseudo-refs from the ref stream, and the accessor pair came back on the
/// reading half of the contract as [`GitServe::head`] / [`GitServe::set_head`].
///
/// So this reads and writes through that pair, and the pessimism the old comment
/// described is gone with it: against a conformant backend the comparison below
/// could never match, so *"did it move"* was `true` on every open of every
/// healthy repository. It is now exact. That is also why the parameter widened
/// from `&dyn GitOps`: `GitServe: GitOps`, so this is the same store and the same
/// single handle a caller already holds, viewed through the half that can answer
/// the question.
pub fn point_head_at(git: &dyn GitServe, default_branch: &str) -> Result<bool> {
    let want = check_branch(default_branch)?;
    let current = git
        .head()
        .map_err(|err| backend(err.context("reading HEAD to see where it points")))?
        .and_then(|row| row.symref_target);
    if current.as_deref() == Some(want.as_str()) {
        return Ok(false);
    }
    // `set_head_projected`, not `set_head`, and the difference is a promise
    // this function is the proof of: the authority for HEAD is the catalog
    // row's `default_branch` column, and this runs on **every** open of every
    // repository (`Stores::repo_store`), so a HEAD frame a power cut took is
    // written again the next time anything touches the store. That lets the
    // znippy arm skip a barrier — 6.0 ms of a 68.5 ms push-create on the 2 vCPU
    // rig — for a fact it would rebuild anyway. A backend with nothing cheaper
    // to do gets the default, which is `set_head`.
    git.set_head_projected(&want)
        .map_err(|err| backend(err.context(format!("pointing HEAD at {default_branch}"))))?;
    Ok(true)
}

/// A backend fault from [`GitOps`](gunnar_store::git_storage_trait::GitOps), which
/// answers in `anyhow`, as this crate's
/// [`Error::Store`].
///
/// `GitOps` has no typed rejection — `update_ref`'s lost compare-and-swap and a
/// broken disk arrive as the same `anyhow::Error`. Nothing in
/// this file needs to tell them apart; anything that does must not do it by
/// matching on the message.
fn backend(err: anyhow::Error) -> Error {
    Error::Store(gunnar_store::Error::Backend(err))
}