Skip to main content

gunnar_core/
catalog.rs

1//! The repository catalog: namespaces and repositories, keyed by
2//! `<namespace>/<repo>`.
3//!
4//! Namespaces are first-class from day one (multi-tenancy): every path a client
5//! sends has one, every grant can scope to one, and a repository cannot exist
6//! outside one. Adding that later would have meant migrating every stored path
7//! and every ACL row.
8//!
9//! The catalog stores **metadata only**. Creating a repository here does not
10//! create its object or ref store, and deleting it does not delete them. That
11//! belongs to whoever owns the backend (the storage seam), which on an appliance is one
12//! `.znippy` per repository and on a normal OS is a directory. Keeping the two
13//! apart is what lets `gunnar gc` and a future `repack()` run without the
14//! catalog knowing anything about storage layout.
15
16use std::sync::Arc;
17
18use gunnar_store::git_storage_trait::GitServe;
19
20use crate::control::ControlStore;
21use crate::doc::{impl_doc, DocRef, ShardedMap};
22use crate::error::{Error, Result};
23use crate::model::{check_branch, HashKind, Namespace, Repo, Visibility, DEFAULT_BRANCH};
24use crate::sandbox::{check_component, RepoRef};
25
26/// **The name the catalog document is stored under** in the control store.
27/// See [`crate::AUTH_REF`] for why it still looks like a reference name.
28pub const CATALOG_REF: &str = "refs/gunnar/catalog";
29
30/// The catalog as it is stored.
31///
32/// Both maps are sharded (`doc.rs`). Inline, `repos` held every repository on
33/// the server in one blob, so `git repo create` re-serialised and re-hashed the
34/// whole catalog and every SSH connection parsed it to answer one key probe.
35/// Sharded, a create rewrites one leaf and the two nodes above it, and
36/// a lookup reads one leaf (none at all, once it is cached).
37#[derive(Default, Clone, serde::Serialize, serde::Deserialize)]
38#[serde(deny_unknown_fields)]
39struct CatalogDoc {
40    /// Keyed by name so the encoding is deterministic.
41    #[serde(default)]
42    namespaces: ShardedMap<Namespace>,
43    /// Keyed by `<namespace>/<repo>`.
44    #[serde(default)]
45    repos: ShardedMap<Repo>,
46}
47
48impl_doc!(CatalogDoc { namespaces, repos });
49
50/// What a new repository is created with.
51///
52/// Server defaults are applied here rather than by the caller, so the gRPC
53/// service, the CLI and a test all get the same repository (one implementation).
54#[derive(Clone, Debug)]
55pub struct NewRepo {
56    /// Where it lives.
57    pub path: RepoRef,
58    /// sha1 or sha256. Defaults to sha256.
59    pub hash_kind: HashKind,
60    /// Full ref name. `None` means [`DEFAULT_BRANCH`].
61    pub default_branch: Option<String>,
62    /// Defaults to [`Visibility::Private`].
63    pub visibility: Visibility,
64    /// Free text, for UIs.
65    pub description: String,
66}
67
68impl NewRepo {
69    /// A private sha256 repository at `path`, with the default branch.
70    pub fn new(path: RepoRef) -> Self {
71        NewRepo {
72            path,
73            hash_kind: HashKind::Sha256,
74            default_branch: None,
75            visibility: Visibility::Private,
76            description: String::new(),
77        }
78    }
79
80    /// Set the hash kind. sha1 repositories are fully supported.
81    pub fn hash_kind(mut self, kind: HashKind) -> Self {
82        self.hash_kind = kind;
83        self
84    }
85
86    /// Set the visibility.
87    pub fn visibility(mut self, visibility: Visibility) -> Self {
88        self.visibility = visibility;
89        self
90    }
91
92    /// Set the default branch, as a full `refs/heads/...` name.
93    pub fn default_branch(mut self, name: impl Into<String>) -> Self {
94        self.default_branch = Some(name.into());
95        self
96    }
97
98    /// The [`Repo`] this spec would create, **without creating it**.
99    ///
100    /// The one place a `NewRepo` becomes a `Repo`: [`RepoCatalog::create`]
101    /// writes exactly this value, and the first-repository door carries it
102    /// through a push's advertisement *before* the catalog has been touched, so
103    /// a client that takes one look at the advertised hash and hangs up (a sha1
104    /// working tree against the sha256 default) leaves nothing behind. A
105    /// door that wrote the catalog first was the 2026-08-30 ghost-repository
106    /// bug: `repo get` succeeded for a repository whose push was refused.
107    pub fn prospect(&self) -> Result<Repo> {
108        let branch = match &self.default_branch {
109            Some(name) => check_branch(name)?,
110            None => DEFAULT_BRANCH.to_owned(),
111        };
112        Ok(Repo {
113            namespace: self.path.namespace().to_owned(),
114            name: self.path.name().to_owned(),
115            hash_kind: self.hash_kind,
116            default_branch: branch,
117            visibility: self.visibility,
118            created: crate::model::now_ms(),
119            description: self.description.clone(),
120            encrypted: false,
121        })
122    }
123}
124
125/// Create, open, list and delete repositories.
126///
127/// Cheap to clone; every clone reads and writes the same document.
128#[derive(Clone, Debug)]
129pub struct RepoCatalog {
130    doc: DocRef<CatalogDoc>,
131}
132
133impl RepoCatalog {
134    /// Open the catalog held in `store` (the certificate-identity plan).
135    ///
136    /// This is the *control* store: the server's own, not a repository's.
137    pub fn new(store: Arc<dyn ControlStore>) -> Result<Self> {
138        Ok(RepoCatalog {
139            doc: DocRef::new(store, CATALOG_REF)?,
140        })
141    }
142
143    /// The name the catalog is stored under.
144    pub fn ref_name(&self) -> &str {
145        self.doc.ref_name()
146    }
147
148    /// Every version this catalog has ever had, oldest first (the certificate-identity plan). See
149    /// [`crate::AuthTable::history`].
150    pub fn history(&self) -> Result<Vec<crate::control::DocVersion>> {
151        self.doc.history()
152    }
153
154    // ── namespaces ─────────────────────────────────────────────────────────
155
156    /// Create a namespace.
157    ///
158    /// Nested names (`team/sub`) are allowed and every level is validated by
159    /// the sandbox's component rules, so a namespace can never be a path
160    /// escape.
161    pub fn create_namespace(&self, name: &str) -> Result<Namespace> {
162        for level in name.split('/') {
163            check_component(name, level)?;
164        }
165        if name.split('/').count() >= crate::sandbox::MAX_COMPONENTS {
166            // One component has to be left for the repository name.
167            return Err(Error::InvalidPath {
168                input: name.escape_debug().to_string(),
169                reason: crate::sandbox::PathReject::TooManyComponents,
170            });
171        }
172        let namespace = Namespace {
173            name: name.to_owned(),
174            // A new account takes the server's default until an admin sets a
175            // plan. `None` and not a copy of the default: copying it would
176            // freeze today's default into every namespace ever created, and a
177            // later change to the appliance's disk would not reach them.
178            explode_budget_bytes: None,
179            // Private, and its events too, until its account admin says
180            // otherwise (`RepoCatalog::set_namespace_public`,
181            // `RepoCatalog::set_namespace_events_public`).
182            public: false,
183            events_public: false,
184        };
185        self.doc.update(|doc| {
186            if doc.namespaces.contains_key(name)? {
187                return Err(Error::NamespaceExists(name.to_owned()));
188            }
189            doc.namespaces.insert(name.to_owned(), namespace.clone())?;
190            Ok(namespace.clone())
191        })
192    }
193
194    /// **Set (or clear) an account's exploded-cache quota.**
195    ///
196    /// `None` puts the namespace back on the server's default; `Some(0)` is
197    /// unlimited and is a decision rather than an absence. The quota reaches a
198    /// repository the next time its store is opened, and every store already
199    /// open under this namespace immediately — see
200    /// `ServerStores::apply_explode_quota`.
201    ///
202    /// It cannot fail a read and it cannot lose an object: the cache it bounds
203    /// is re-derivable from the packs, which it does not bound. Lowering a
204    /// quota below what a repository already holds evicts; it never refuses.
205    pub fn set_namespace_explode_budget(
206        &self,
207        name: &str,
208        bytes: Option<u64>,
209    ) -> Result<Namespace> {
210        self.doc.update(|doc| {
211            let Some(mut ns) = doc.namespaces.get(name)? else {
212                return Err(Error::NoSuchNamespace(name.to_owned()));
213            };
214            ns.explode_budget_bytes = bytes;
215            doc.namespaces.insert(name.to_owned(), ns.clone())?;
216            Ok(ns)
217        })
218    }
219
220    /// This namespace's quota, or `None` for "the server's default". A missing
221    /// namespace is `None` too: a repository whose namespace row is gone is a
222    /// broken catalog, and answering "the default" is the safe reading of it.
223    pub fn namespace_explode_budget(&self, name: &str) -> Result<Option<u64>> {
224        Ok(self
225            .doc
226            .load()?
227            .namespaces
228            .get(name)?
229            .and_then(|ns| ns.explode_budget_bytes))
230    }
231
232    /// **Make a namespace public, or private again** ([`Namespace::public`]).
233    ///
234    /// Only the stored fact: who may change it (the account admin) is the gRPC
235    /// layer's question (`Repos.SetNamespaceSettings`). It touches no
236    /// repository — a private repository stays private in a public namespace.
237    pub fn set_namespace_public(&self, name: &str, public: bool) -> Result<Namespace> {
238        self.update_namespace(name, |ns| ns.public = public)
239    }
240
241    /// **Make an account's emitted events public, or private again**
242    /// ([`Namespace::events_public`]). The stored fact only, as
243    /// [`RepoCatalog::set_namespace_public`].
244    pub fn set_namespace_events_public(&self, name: &str, public: bool) -> Result<Namespace> {
245        self.update_namespace(name, |ns| ns.events_public = public)
246    }
247
248    /// One namespace row, changed in one compare-and-swap.
249    fn update_namespace(&self, name: &str, change: impl Fn(&mut Namespace)) -> Result<Namespace> {
250        self.doc.update(|doc| {
251            let Some(mut ns) = doc.namespaces.get(name)? else {
252                return Err(Error::NoSuchNamespace(name.to_owned()));
253            };
254            change(&mut ns);
255            doc.namespaces.insert(name.to_owned(), ns.clone())?;
256            Ok(ns)
257        })
258    }
259
260    /// **The catalog's version**: changes exactly when any namespace or
261    /// repository row does. One pointer read — what lets a reader that keeps
262    /// its own per-namespace index of the catalog (the server's usage ledger)
263    /// rebuild it only when something changed, instead of listing every
264    /// repository on every read.
265    pub fn version(&self) -> Result<Option<crate::control::BlobId>> {
266        self.doc.version()
267    }
268
269    /// Every namespace, by name. **O(namespaces)**, the listing path.
270    pub fn namespaces(&self) -> Result<Vec<Namespace>> {
271        self.doc.load()?.namespaces.values()
272    }
273
274    /// One namespace, if it exists.
275    pub fn namespace(&self, name: &str) -> Result<Option<Namespace>> {
276        self.doc.load()?.namespaces.get(name)
277    }
278
279    /// Delete a namespace, and return the repositories that went with it.
280    ///
281    /// Refuses a namespace that still holds repositories unless `delete_repos`.
282    /// Deleting a tenant's repositories has to be asked for explicitly.
283    pub fn delete_namespace(&self, name: &str, delete_repos: bool) -> Result<Vec<Repo>> {
284        self.doc.update(|doc| {
285            if !doc.namespaces.contains_key(name)? {
286                return Err(Error::NoSuchNamespace(name.to_owned()));
287            }
288            // O(repositories on the server): a tenant's repositories are not
289            // indexed apart from every other tenant's, because the connection
290            // path looks a repository up by its full key and never by namespace.
291            let doomed: Vec<Repo> = doc
292                .repos
293                .values()?
294                .into_iter()
295                .filter(|repo| repo.namespace == name)
296                .collect();
297            if !doomed.is_empty() && !delete_repos {
298                return Err(Error::NamespaceNotEmpty {
299                    name: name.to_owned(),
300                    repos: doomed.len(),
301                });
302            }
303            for repo in &doomed {
304                doc.repos.remove(&repo.key())?;
305            }
306            doc.namespaces.remove(name)?;
307            Ok(doomed)
308        })
309    }
310
311    // ── repositories ───────────────────────────────────────────────────────
312
313    /// Create a repository.
314    ///
315    /// The namespace must already exist. A repository is never allowed to
316    /// conjure one, because then a typo in a push URL would silently become a
317    /// tenant.
318    pub fn create(&self, spec: NewRepo) -> Result<Repo> {
319        let repo = spec.prospect()?;
320        let key = spec.path.key();
321        self.doc.update(|doc| {
322            if !doc.namespaces.contains_key(repo.namespace.as_str())? {
323                return Err(Error::NoSuchNamespace(repo.namespace.clone()));
324            }
325            if doc.repos.contains_key(&key)? {
326                return Err(Error::RepoExists(spec.path.clone()));
327            }
328            doc.repos.insert(key.clone(), repo.clone())?;
329            Ok(repo.clone())
330        })
331    }
332
333    /// Look a repository up, failing if it is not there.
334    ///
335    /// This is what the SSH layer calls after the sandbox has produced a
336    /// [`RepoRef`]: a path that parses is not a repository that exists.
337    pub fn open(&self, path: &RepoRef) -> Result<Repo> {
338        self.get(path)?
339            .ok_or_else(|| Error::NoSuchRepo(path.clone()))
340    }
341
342    /// Look a repository up.
343    ///
344    /// **This is the connection path**. `Control::resolve` calls it for every
345    /// SSH session. One shard probe, and no object read at all once the catalog
346    /// has been read since it last changed.
347    pub fn get(&self, path: &RepoRef) -> Result<Option<Repo>> {
348        self.doc.load()?.repos.get(&path.key())
349    }
350
351    /// **Many rows, one read of the catalog** (round 5.3.1): `get` for each
352    /// path, the document's pointer read once rather than once per row — a
353    /// listing's access check asks about every row it lists.
354    pub fn get_many(&self, paths: &[RepoRef]) -> Result<Vec<Option<Repo>>> {
355        let doc = self.doc.load()?;
356        paths.iter().map(|p| doc.repos.get(&p.key())).collect()
357    }
358
359    /// Is this repository in the catalog?
360    pub fn contains(&self, path: &RepoRef) -> Result<bool> {
361        self.doc.load()?.repos.contains_key(&path.key())
362    }
363
364    /// Every repository, or every repository in one namespace, ordered by key.
365    ///
366    /// **O(repositories)** either way; see [`RepoCatalog::delete_namespace`].
367    pub fn list(&self, namespace: Option<&str>) -> Result<Vec<Repo>> {
368        let doc = self.doc.load()?;
369        Ok(doc
370            .repos
371            .values()?
372            .into_iter()
373            .filter(|repo| namespace.is_none_or(|ns| repo.namespace == ns))
374            .collect())
375    }
376
377    /// Remove a repository's metadata and hand it back.
378    ///
379    /// The caller owns the storage: whatever backend held its objects and refs
380    /// is still there afterwards and must be dealt with separately.
381    pub fn delete(&self, path: &RepoRef) -> Result<Repo> {
382        let key = path.key();
383        self.doc.update(|doc| {
384            doc.repos
385                .remove(&key)?
386                .ok_or_else(|| Error::NoSuchRepo(path.clone()))
387        })
388    }
389
390    /// Record another default branch for a repository.
391    ///
392    /// This writes the **catalog** and nothing else. The repository's own
393    /// `HEAD` is a projection of this value and is moved by
394    /// [`point_head_at`], which whoever owns the repository's stores calls
395    /// with the `Repo` this hands back. Doing it here is not possible: the
396    /// catalog holds metadata only and has never had a repository's ref store
397    /// (see this module's opening comment).
398    pub fn set_default_branch(&self, path: &RepoRef, branch: &str) -> Result<Repo> {
399        let branch = check_branch(branch)?;
400        let key = path.key();
401        self.doc.update(|doc| {
402            let mut repo = doc
403                .repos
404                .get(&key)?
405                .ok_or_else(|| Error::NoSuchRepo(path.clone()))?;
406            repo.default_branch = branch.clone();
407            doc.repos.insert(key.clone(), repo.clone())?;
408            Ok(repo)
409        })
410    }
411
412    /// Change a repository's visibility.
413    pub fn set_visibility(&self, path: &RepoRef, visibility: Visibility) -> Result<Repo> {
414        let key = path.key();
415        self.doc.update(|doc| {
416            let mut repo = doc
417                .repos
418                .get(&key)?
419                .ok_or_else(|| Error::NoSuchRepo(path.clone()))?;
420            repo.visibility = visibility;
421            doc.repos.insert(key.clone(), repo.clone())?;
422            Ok(repo)
423        })
424    }
425
426    /// Turn a repository's "encrypted" flag on or off (`Repos.SetEncrypted`;
427    /// the account admin's decision, checked by the caller).
428    pub fn set_encrypted(&self, path: &RepoRef, encrypted: bool) -> Result<Repo> {
429        let key = path.key();
430        self.doc.update(|doc| {
431            let mut repo = doc
432                .repos
433                .get(&key)?
434                .ok_or_else(|| Error::NoSuchRepo(path.clone()))?;
435            repo.encrypted = encrypted;
436            doc.repos.insert(key.clone(), repo.clone())?;
437            Ok(repo)
438        })
439    }
440
441    /// How many namespaces and repositories there are, for `Admin.Stats`.
442    pub fn counts(&self) -> Result<(usize, usize)> {
443        let doc = self.doc.load()?;
444        Ok((doc.namespaces.len(), doc.repos.len()))
445    }
446}
447
448/// Make a repository's `HEAD` name the default branch its catalog entry
449/// carries, and say whether it had to move.
450///
451/// # The bug this exists to make impossible
452///
453/// The catalog is the **authority** for a repository's default branch. A
454/// repository's own `HEAD` is a **projection** of that value, and this is the
455/// only writer of the projection (one implementation).
456///
457/// Until 2026-08-06 there was no projection at all, so the two disagreed the
458/// moment anyone moved the authority. `FsStore::open_or_init` wrote
459/// `ref: refs/heads/main` into a new repository and nothing ever wrote it
460/// again; `Repos.SetDefaultBranch` wrote the catalog and nothing else. So
461/// `set-default-branch master` answered `refs/heads/master`, `repo get` read
462/// `refs/heads/master` back — and the reference advertisement, which reads the
463/// repository's `HEAD` and not the catalog, still resolved
464/// `refs/heads/main`. That branch had never been created, so `HEAD` had no
465/// object, so it was **dropped from the advertisement entirely** and every
466/// `master`-default donor (`foot`, `ripgrep`, `mdBook`) pushed cleanly into a
467/// repository nobody could clone back. MEASURED that day: `ls-remote` returned
468/// one line, `refs/heads/master`, and no `HEAD` line at all.
469///
470/// # Why it takes a `&dyn GitServe` and not a `&RepoCatalog`
471///
472/// The catalog stores metadata only and has never held a repository's stores;
473/// the caller that opens those is the one that can hand both halves over. That
474/// caller is `ServerStores::repo_store`, which runs on create, on
475/// `set-default-branch` and on the first transfer after a restart — so an
476/// already-broken repository on disk is repaired the next time it is opened,
477/// with no migration step.
478///
479/// A symbolic `HEAD` is written whether or not the branch exists. An unborn
480/// `HEAD` is legal and is exactly what a freshly created, never-pushed
481/// repository has; what is not legal is a `HEAD` naming a branch the owner
482/// never asked for.
483///
484/// # ⚠ The contract point this depended on is SETTLED — 2026-08-10, the `GitServe` HEAD ruling
485///
486/// This used to be `RefStore::head()` / `set_head()`, and when that pair died it
487/// was rewritten against `GitOps::refs()` / `put_refs()` with a warning attached:
488/// *whether `refs()` includes a row for `HEAD` is an open question … both cannot
489/// be right*. It is answered. **`HEAD` is not a row.** Every backend excludes
490/// pseudo-refs from the ref stream, and the accessor pair came back on the
491/// reading half of the contract as [`GitServe::head`] / [`GitServe::set_head`].
492///
493/// So this reads and writes through that pair, and the pessimism the old comment
494/// described is gone with it: against a conformant backend the comparison below
495/// could never match, so *"did it move"* was `true` on every open of every
496/// healthy repository. It is now exact. That is also why the parameter widened
497/// from `&dyn GitOps`: `GitServe: GitOps`, so this is the same store and the same
498/// single handle a caller already holds, viewed through the half that can answer
499/// the question.
500pub fn point_head_at(git: &dyn GitServe, default_branch: &str) -> Result<bool> {
501    let want = check_branch(default_branch)?;
502    let current = git
503        .head()
504        .map_err(|err| backend(err.context("reading HEAD to see where it points")))?
505        .and_then(|row| row.symref_target);
506    if current.as_deref() == Some(want.as_str()) {
507        return Ok(false);
508    }
509    // `set_head_projected`, not `set_head`, and the difference is a promise
510    // this function is the proof of: the authority for HEAD is the catalog
511    // row's `default_branch` column, and this runs on **every** open of every
512    // repository (`Stores::repo_store`), so a HEAD frame a power cut took is
513    // written again the next time anything touches the store. That lets the
514    // znippy arm skip a barrier — 6.0 ms of a 68.5 ms push-create on the 2 vCPU
515    // rig — for a fact it would rebuild anyway. A backend with nothing cheaper
516    // to do gets the default, which is `set_head`.
517    git.set_head_projected(&want)
518        .map_err(|err| backend(err.context(format!("pointing HEAD at {default_branch}"))))?;
519    Ok(true)
520}
521
522/// A backend fault from [`GitOps`](gunnar_store::git_storage_trait::GitOps), which
523/// answers in `anyhow`, as this crate's
524/// [`Error::Store`].
525///
526/// `GitOps` has no typed rejection — `update_ref`'s lost compare-and-swap and a
527/// broken disk arrive as the same `anyhow::Error`. Nothing in
528/// this file needs to tell them apart; anything that does must not do it by
529/// matching on the message.
530fn backend(err: anyhow::Error) -> Error {
531    Error::Store(gunnar_store::Error::Backend(err))
532}