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}