Skip to main content

gunnar_ssh/
control.rs

1//! The control plane this listener answers from: `gunnar-core`, directly
2//! (the ACL model).
3//!
4//! # What this replaced
5//!
6//! Two local traits, `seam::AuthDirectory` and `seam::RepoResolver`, written
7//! while `gunnar-core` was being built in parallel. They are gone, and this is
8//! not a trait: there is one identity table and one catalog, they are
9//! `gunnar-core`'s, and putting an interface in front of them would only be
10//! somewhere for a second answer to live (one implementation).
11//!
12//! # The bug the seam had, which is why this is a rewrite and not a rename
13//!
14//! Both seam traits returned `Option`. `gunnar-core` returns `Result`, and
15//! deliberately: the table lives in storage (the Arrow-IPC ref log), a read can fail, and
16//! the ACL model spells out that `may(...)` must not be a bare `bool` because the
17//! safe-looking invention for the failure case ("no access") turns a storage
18//! fault into a silent permission denial. Any adapter from `Result` to `Option`
19//! has to make exactly that invention.
20//!
21//! So the `Result` is carried all the way through
22//! [`Dispatch::authorise`](crate::server::Dispatch::authorise) and lands as its
23//! own [`DispatchError::Store`](crate::server::DispatchError::Store) variant:
24//! separate from `Denied`, logged as a fault, and never reported to the client
25//! as "you may not have this repository".
26
27use std::sync::atomic::{AtomicBool, Ordering};
28use std::sync::Arc;
29
30use gunnar_core::{
31    AuthTable, ControlStore, Fingerprint, HashKind, Level, NewRepo, Principal, PrincipalKind, Repo,
32    RepoCatalog, RepoRef,
33};
34
35/// The identity table and the repository catalog, together.
36///
37/// Cheap to clone: both halves are handles onto the same stored documents.
38#[derive(Clone)]
39pub struct Control {
40    auth: AuthTable,
41    catalog: RepoCatalog,
42    /// **Is this box a frozen STANDBY?** `true` refuses a client `git push`.
43    ///
44    /// Shared by clone with the server's replication slot, which flips it on a
45    /// role change — so this front door and the control plane read ONE value
46    /// (one implementation) and never disagree about whether a write should be refused. The
47    /// default is an unshared `false`: a bare `Control` (a test, a build with no
48    /// replication) is a writable primary, exactly as before this existed.
49    writes_frozen: Arc<AtomicBool>,
50    /// **Is this box a browse replica**? Shared with
51    /// the replication slot the same way `writes_frozen` is; read for the
52    /// refusal WORDING (a browse box's refusal names itself) and to override
53    /// [`AnonymousPolicy::Refuse`] — the browse replica exists to serve
54    /// anonymous reads, so it serves them whatever the flag says.
55    browse: Arc<AtomicBool>,
56    /// **Is this box a TWIN right now?** `true` refuses every git service —
57    /// read and write, keyed and anonymous — at
58    /// [`Dispatch::authorise_service`](crate::server::Dispatch::authorise_service).
59    ///
60    /// Shared by clone with `ServerStores`' own flag, the way `writes_frozen`
61    /// and `browse` are, so the front door and the control plane read ONE value
62    /// and can never disagree. It has to be live, not a snapshot: a box is made
63    /// a twin over the wire by `Bootstrap.BecomeTwin` while it is already
64    /// running and serving, and under the owner's one-medium ruling that is the
65    /// ONLY way a twin is ever made. The default is an unshared `false` — a
66    /// bare `Control` is a primary, exactly as before this existed.
67    twin: Arc<AtomicBool>,
68    /// **Does this box serve anonymous reads at all?** The primary of a
69    /// browse-replica deployment says `Refuse`, and names where to go.
70    anonymous: AnonymousPolicy,
71    /// **The gate chain**: the server's answers to "may this namespace push
72    /// right now", asked IN ORDER by `Dispatch::authorise_service`; the first
73    /// `Refuse` wins. The server wires the disk-headroom gate first and, on a
74    /// `metered` build with a monetize key, the entitlement gate second. Empty
75    /// — a bare `Control`, a test — is every namespace Free with infinite disk,
76    /// which is what a test that is not about the gates wants.
77    plan: Vec<Arc<dyn crate::plan::PlanGate>>,
78    /// **The owner's enterprise list at the push door** (2026-10-05): the
79    /// name filter a first push's repository name is put to, and the
80    /// accounts' audit logs its creation is written into. `None` — a bare
81    /// `Control`, a test not about them — filters nothing and logs nothing.
82    enterprise: Option<(
83        gunnar_core::name_filter::NameFilterStore,
84        Arc<gunnar_core::account_audit::AccountAuditLog>,
85    )>,
86    /// People's encryption keys (CRYPT-YUBI): the `gunnar-user-key` door and the
87    /// recipient answer read and write them. `None`: a door without them.
88    encryption_keys: Option<gunnar_core::encryption_key::EncryptionKeys>,
89    /// **The pull-mirror registry** (GUN-FIELD-TEST, 2026-10-31): a person's push into a
90    /// repository it names is refused, because the next sync moves its references back to
91    /// the upstream's. `None`: a door without it (a test) refuses nothing for this.
92    mirrors: Option<gunnar_core::MirrorStore>,
93}
94
95/// **The `--anonymous` knob** (the browse replica). Anonymous reads of
96/// public repositories are either served here (`Allow`, the default and every
97/// pre-2026-09 server) or refused with a line that names the browse replica
98/// they are served by (`Refuse`). Strangers cost CPU and bandwidth on the box
99/// that also takes the paying writes; the browse replica is where that cost
100/// belongs, and the primary sends them there rather than quietly 404-ing.
101#[derive(Debug, Clone, PartialEq, Eq, Default)]
102pub enum AnonymousPolicy {
103    /// Serve anonymous reads of public repositories on this box.
104    #[default]
105    Allow,
106    /// Refuse every anonymous request; `browse_host`, when named, goes into
107    /// the refusal so `git clone` prints where to clone from instead.
108    Refuse {
109        /// `host[:port]` of the browse replica, for the refusal line.
110        browse_host: Option<String>,
111    },
112}
113
114impl Control {
115    /// Wrap an already-open table and catalog. Writable (a primary) until
116    /// [`Control::with_write_gate`] shares a standby's freeze flag in.
117    pub fn new(auth: AuthTable, catalog: RepoCatalog) -> Self {
118        Control {
119            auth,
120            catalog,
121            writes_frozen: Arc::new(AtomicBool::new(false)),
122            browse: Arc::new(AtomicBool::new(false)),
123            twin: Arc::new(AtomicBool::new(false)),
124            anonymous: AnonymousPolicy::Allow,
125            plan: Vec::new(),
126            enterprise: None,
127            encryption_keys: None,
128            mirrors: None,
129        }
130    }
131
132    /// The pull-mirror registry this door reads.
133    #[must_use]
134    pub fn with_mirrors(mut self, mirrors: gunnar_core::MirrorStore) -> Self {
135        self.mirrors = Some(mirrors);
136        self
137    }
138
139    /// **Is `repo` a pull mirror, and of what?** The upstream's URL when the registry names
140    /// it; `None` when it does not, or when this door holds no registry.
141    pub fn pull_mirror_of(&self, repo: &RepoRef) -> Result<Option<String>, gunnar_core::Error> {
142        let Some(mirrors) = &self.mirrors else {
143            return Ok(None);
144        };
145        Ok(mirrors.get(repo)?.map(|record| record.remote_url))
146    }
147
148    /// The store of people's encryption keys this door reads and writes.
149    #[must_use]
150    pub fn with_encryption_keys(
151        mut self,
152        keys: gunnar_core::encryption_key::EncryptionKeys,
153    ) -> Self {
154        self.encryption_keys = Some(keys);
155        self
156    }
157
158    /// People's encryption keys, when this door holds them.
159    pub fn encryption_keys(&self) -> Option<&gunnar_core::encryption_key::EncryptionKeys> {
160        self.encryption_keys.as_ref()
161    }
162
163    /// **The name filter and the accounts' audit logs** (2026-10-05): a
164    /// repository a first push creates is named here too, and its creation is
165    /// in its account's log.
166    #[must_use]
167    pub fn with_enterprise(
168        mut self,
169        names: gunnar_core::name_filter::NameFilterStore,
170        audit: Arc<gunnar_core::account_audit::AccountAuditLog>,
171    ) -> Self {
172        self.enterprise = Some((names, audit));
173        self
174    }
175
176    /// Write one event into the account `namespace` belongs to, if a person
177    /// owns it. Best effort: one WARN when the log cannot be written.
178    fn account_event(
179        &self,
180        principal: &Principal,
181        namespace: &str,
182        kind: &str,
183        target: &str,
184        detail: &str,
185        refused: bool,
186    ) {
187        let Some((_, log)) = &self.enterprise else {
188            return;
189        };
190        let Ok(Some(account)) = self.auth.account_of_namespace(namespace) else {
191            return;
192        };
193        let role = self
194            .auth
195            .role_in_account(&principal.id, &account)
196            .unwrap_or("unknown");
197        let written = log.append(
198            &account,
199            gunnar_core::account_audit::AccountEvent {
200                kind,
201                actor: &principal.id,
202                actor_role: role,
203                target,
204                detail,
205                outcome: if refused {
206                    gunnar_core::account_audit::Outcome::Refused
207                } else {
208                    gunnar_core::account_audit::Outcome::Ok
209                },
210            },
211            gunnar_core::now_ms(),
212        );
213        if let Err(err) = written {
214            tracing::warn!(target: "gunnar::audit", event = "account_audit.not_written", account = %account, kind, error = %err, "an event could not be written to its account's audit log");
215        }
216    }
217
218    /// **An event about a person's own encryption key** (RA4-3): written into
219    /// the account the person belongs to, the same kinds the gRPC door writes
220    /// (`encryption-key.created`, `.method-added`, `.method-removed`).
221    pub fn encryption_key_event(&self, principal: &Principal, kind: &str, detail: &str) {
222        let Some((_, log)) = &self.enterprise else {
223            return;
224        };
225        let Ok(Some(account)) = self.auth.account_of_principal(&principal.id) else {
226            return;
227        };
228        let role = self
229            .auth
230            .role_in_account(&principal.id, &account)
231            .unwrap_or("unknown");
232        let written = log.append(
233            &account,
234            gunnar_core::account_audit::AccountEvent {
235                kind,
236                actor: &principal.id,
237                actor_role: role,
238                target: &principal.id,
239                detail,
240                outcome: gunnar_core::account_audit::Outcome::Ok,
241            },
242            gunnar_core::now_ms(),
243        );
244        if let Err(err) = written {
245            tracing::warn!(target: "gunnar::audit", event = "account_audit.not_written", account = %account, kind, error = %err, "an event could not be written to its account's audit log");
246        }
247    }
248
249    /// **The name filter at the push door**: the sentence a first push to
250    /// `path` is refused with, or `None`. A blocked name is noted for the
251    /// account admin; a borderline one is held for them (and refused until
252    /// they allow it). Reserved names are a person's and an account's, never
253    /// a repository's.
254    pub fn push_create_name_refusal(
255        &self,
256        principal: &Principal,
257        path: &RepoRef,
258    ) -> Option<String> {
259        use gunnar_core::name_filter::{NameKind, Verdict};
260        let (names, _) = self.enterprise.as_ref()?;
261        let account = self
262            .auth
263            .account_of_namespace(path.namespace())
264            .ok()
265            .flatten();
266        let verdict = names
267            .check(
268                NameKind::Repo,
269                path.name(),
270                account.as_deref().unwrap_or(""),
271            )
272            .ok()?;
273        if verdict == Verdict::Clean || matches!(verdict, Verdict::Reserved { .. }) {
274            return None;
275        }
276        let hold = matches!(verdict, Verdict::Borderline { .. }) && account.is_some();
277        if let Some(account) = &account {
278            let now = gunnar_core::now_ms();
279            let queued = if hold {
280                names.hold(
281                    NameKind::Repo,
282                    path.name(),
283                    account,
284                    &principal.id,
285                    &verdict,
286                    now,
287                )
288            } else {
289                names.note(
290                    NameKind::Repo,
291                    path.name(),
292                    account,
293                    &principal.id,
294                    &verdict,
295                    now,
296                )
297            };
298            if let Err(err) = queued {
299                tracing::warn!(target: "gunnar::audit", event = "name_filter.not_queued", account = %account, error = %err, "a name the filter stopped could not be put in its account's queue");
300            }
301        }
302        self.account_event(
303            principal,
304            path.namespace(),
305            if hold { "name.held" } else { "name.refused" },
306            path.name(),
307            &format!(
308                "a repo name, {} by the name filter, at a first push",
309                verdict.as_str()
310            ),
311            true,
312        );
313        Some(verdict.sentence(NameKind::Repo, path.name()))
314    }
315
316    /// **Share the standby write-gate in.** The `Arc` the server's replication
317    /// slot writes on a role change; reading it is how the front door learns,
318    /// per push, that this box has become a read-only standby.
319    #[must_use]
320    pub fn with_write_gate(mut self, writes_frozen: Arc<AtomicBool>) -> Self {
321        self.writes_frozen = writes_frozen;
322        self
323    }
324
325    /// Share the browse flag in — `ServerStores::browse()`, the same `Arc`
326    /// the replication slot flips.
327    #[must_use]
328    pub fn with_browse_flag(mut self, browse: Arc<AtomicBool>) -> Self {
329        self.browse = browse;
330        self
331    }
332
333    /// Set the anonymous policy. `Refuse` is overridden by the browse flag:
334    /// see [`Control::refuses_anonymous`].
335    #[must_use]
336    pub fn with_anonymous_policy(mut self, policy: AnonymousPolicy) -> Self {
337        self.anonymous = policy;
338        self
339    }
340
341    /// Share the twin flag in — `ServerStores::twin_flag()`, the same `Arc`
342    /// `Bootstrap.BecomeTwin` flips while the server runs.
343    #[must_use]
344    pub fn with_twin_flag(mut self, twin: Arc<AtomicBool>) -> Self {
345        self.twin = twin;
346        self
347    }
348
349    /// Is this box a browse replica right now?
350    pub fn is_browse(&self) -> bool {
351        self.browse.load(Ordering::SeqCst)
352    }
353
354    /// **Is this box a twin right now?** A live read: the answer changes under
355    /// a running server the moment `Bootstrap.BecomeTwin` returns.
356    pub fn is_twin(&self) -> bool {
357        self.twin.load(Ordering::SeqCst)
358    }
359
360    /// **Is an anonymous request refused here?** `Refuse` on a box that is
361    /// not a browse replica. A browse replica serves anonymous reads whatever
362    /// the knob says — it is the box the knob points AT, and a deployment that
363    /// flipped a primary into a browse box by `SetRole` must not carry the
364    /// primary's refusal along.
365    pub fn refuses_anonymous(&self) -> bool {
366        matches!(self.anonymous, AnonymousPolicy::Refuse { .. }) && !self.is_browse()
367    }
368
369    /// The browse host the refusal names, when the operator gave one.
370    pub fn browse_host(&self) -> Option<&str> {
371        match &self.anonymous {
372            AnonymousPolicy::Refuse { browse_host } => browse_host.as_deref(),
373            AnonymousPolicy::Allow => None,
374        }
375    }
376
377    /// **Append a gate to the chain.** Order is the call order: the server
378    /// hands the disk-headroom gate in first, then (`metered`) the entitlement
379    /// gate, so a full box is told it is full before it is told about its
380    /// plan. From here on every push asks each link in
381    /// `Dispatch::authorise_service`, and the first `Refuse` is the answer.
382    #[must_use]
383    pub fn with_plan_gate(mut self, gate: Arc<dyn crate::plan::PlanGate>) -> Self {
384        self.plan.push(gate);
385        self
386    }
387
388    /// The chain, in the order it is asked. Empty when nothing was wired.
389    pub fn plan_gates(&self) -> &[Arc<dyn crate::plan::PlanGate>] {
390        &self.plan
391    }
392
393    /// Open both over the server's control store (the certificate-identity plan).
394    pub fn open(store: Arc<dyn ControlStore>) -> Result<Self, gunnar_core::Error> {
395        Ok(Control {
396            auth: AuthTable::new(Arc::clone(&store))?,
397            encryption_keys: Some(
398                gunnar_core::encryption_key::EncryptionKeys::new(Arc::clone(&store)).map_err(
399                    |e| match e {
400                        gunnar_core::encryption_key::KeyRefusal::Store(e) => e,
401                        other => gunnar_core::Error::ControlStore(other.to_string()),
402                    },
403                )?,
404            ),
405            mirrors: Some(gunnar_core::MirrorStore::new(Arc::clone(&store))?),
406            catalog: RepoCatalog::new(store)?,
407            writes_frozen: Arc::new(AtomicBool::new(false)),
408            browse: Arc::new(AtomicBool::new(false)),
409            twin: Arc::new(AtomicBool::new(false)),
410            anonymous: AnonymousPolicy::Allow,
411            plan: Vec::new(),
412            enterprise: None,
413        })
414    }
415
416    /// **Would a client write be refused right now?** `true` on a standby.
417    ///
418    /// The replication push from a primary is a [`PrincipalKind::Machine`]
419    /// identity and is NOT a client write — a standby exists to receive it —
420    /// so the freeze is scoped to non-`Machine` principals by
421    /// [`Control::write_frozen_for`], which is what the dispatch actually calls.
422    pub fn writes_frozen(&self) -> bool {
423        self.writes_frozen.load(Ordering::SeqCst)
424    }
425
426    /// **Must this principal's write be refused because this box is a standby?**
427    ///
428    /// `true` only when the box is frozen AND the writer is not the replication
429    /// source. A `Machine` identity is how a primary authenticates its
430    /// replication push, and a standby is a box that exists precisely to accept
431    /// that — freezing it would stop the very copy the standby is for. A human
432    /// (`User`) or a `Deploy` key pushing directly to a standby is the write
433    /// that must be refused: it would land here and never reach the primary.
434    pub fn write_frozen_for(&self, principal: &Principal) -> bool {
435        self.writes_frozen() && principal.kind != PrincipalKind::Machine
436    }
437
438    /// The identity table.
439    pub fn auth(&self) -> &AuthTable {
440        &self.auth
441    }
442
443    /// The repository catalog.
444    pub fn catalog(&self) -> &RepoCatalog {
445        &self.catalog
446    }
447
448    /// **Who is this?** The principal that owns `fingerprint`, or `None` to
449    /// refuse the authentication.
450    ///
451    /// `Err` is a storage fault and is *not* the same as `Ok(None)`. The caller
452    /// must refuse the key either way, but it must say which happened, because
453    /// "nobody holds this key" and "the identity table could not be read" call
454    /// for entirely different operator action.
455    pub fn principal_for_key(
456        &self,
457        fingerprint: &Fingerprint,
458    ) -> Result<Option<Principal>, gunnar_core::Error> {
459        self.auth.principal_by_fingerprint(fingerprint)
460    }
461
462    /// **Does it exist?** The repository at `path`, or `None`.
463    ///
464    /// `None` here is only ever "no such repository". It must not be conflated
465    /// with "no permission" at this layer: the server merges the two into one
466    /// client-visible message on purpose, so a client cannot use the difference
467    /// to enumerate private repositories, but they arrive separately so the
468    /// server log can tell them apart.
469    pub fn resolve(&self, path: &RepoRef) -> Result<Option<Repo>, gunnar_core::Error> {
470        self.catalog.get(path)
471    }
472
473    /// **May they?** The highest level `principal` holds on `repo`, or `None`
474    /// for no access at all.
475    ///
476    /// Derived by asking [`AuthTable::may`] from the top down, so this is the
477    /// ACL's answer and not a second reading of the grant table. A namespace
478    /// grant matches its namespace **exactly**: `team` does not reach
479    /// `team/sub`. That is what closes the escalation the permissive path
480    /// sandbox would otherwise carry, and it is pinned by
481    /// `gunnar-core`'s `tests/acl.rs`.
482    pub fn access_for(
483        &self,
484        principal: &Principal,
485        repo: &Repo,
486    ) -> Result<Option<Level>, gunnar_core::Error> {
487        for level in [Level::Admin, Level::Write, Level::Read] {
488            if self.auth.may(&principal.id, repo, level)? {
489                return Ok(Some(level));
490            }
491        }
492        Ok(None)
493    }
494
495    /// **The first-repository door, the checks**: may `principal` create
496    /// `path` by pushing to it? `Some(spec)` is what would be created —
497    /// nothing has been written; `None` the dispatch MUST render as its
498    /// ordinary not-found, so the door is invisible to everyone it does not
499    /// admit.
500    ///
501    /// # Why this exists (the appliance brick, 2026-08-25)
502    ///
503    /// On an installed appliance the bootstrap admin from `--admin-key`
504    /// authenticates and can then do nothing: the exec surface is three git
505    /// verbs, all of which want a repository; the catalog is empty; gRPC —
506    /// the only surface with `Repos.Create` — is off by design;
507    /// and there is no shell by construction. Nobody could ever create the
508    /// first namespace. `a8ba0665` fixed "authorizes nobody"; this fixes
509    /// "can do nothing".
510    ///
511    /// # It asks gRPC's questions, verbatim (one implementation)
512    ///
513    /// This is deliberately NOT a new permission vocabulary. The two
514    /// predicates are exactly the ones the gRPC control plane gates creation
515    /// on, so SSH and gRPC can never disagree about who may create:
516    ///
517    /// * a namespace that does not exist needs what `Repos.CreateNamespace`
518    ///   needs: [`AuthTable::may_server`] at [`Level::Admin`] — creating a
519    ///   tenant is a server-wide act;
520    /// * the repository needs what `Repos.Create` needs:
521    ///   [`AuthTable::may_namespace`] at [`Level::Admin`] — namespace-scoped,
522    ///   because the repository does not exist yet, so there is nothing
523    ///   narrower to hold a grant on.
524    ///
525    /// So the door admits exactly who the RPC would admit: the gunnar-admin
526    /// anywhere, an account owner inside their own (existing) account
527    /// namespace, a namespace-admin inside that namespace exactly (no
528    /// inheritance into `team/sub`). The anonymous principal is refused by
529    /// construction inside both predicates.
530    ///
531    /// # Why the catalog's "never conjure a namespace" rule bends here
532    ///
533    /// [`RepoCatalog::create`] refuses a repository whose namespace does not
534    /// exist, "because then a typo in a push URL would silently become a
535    /// tenant". That rule is about *unprivileged* writers. Here the namespace
536    /// is only ever conjured by a principal holding `Server`/`Admin` — whose
537    /// grant already covers every namespace on the box, so no tenant boundary
538    /// is crossed; the blast radius of the admin's typo is a private, empty
539    /// repository on the admin's own server.
540    ///
541    /// # What it would create
542    ///
543    /// [`NewRepo::new`] and nothing else: sha256, private, default
544    /// branch. The one writer of defaults — a door that chose its own
545    /// defaults would be the second answer one implementation forbids.
546    ///
547    /// # Why the write is NOT here (the ghost repository, 2026-08-30)
548    ///
549    /// This used to create the catalog row at exec time, before the client
550    /// had seen the advertisement. A sha1 working tree pushing to the sha256
551    /// default reads `object-format=sha256` in that advertisement, prints
552    /// *"the receiving end does not support this repository's hash
553    /// algorithm"* and hangs up **having sent no command** — and the empty
554    /// repository it never pushed to was there afterwards: `repo get`
555    /// succeeded, `ls-remote` answered (the site test, two FAIL rows).
556    /// The server cannot know the client's hash before advertising, so the
557    /// only order that leaves nothing behind on a refusal is: advertise the
558    /// *prospect*, read the commands, check the hash, **then** write. The
559    /// write is [`Self::admit_push_create`], called by the transport once
560    /// there is a push to admit; the catalog is untouched until then.
561    pub fn push_create_spec(
562        &self,
563        principal: &Principal,
564        path: &RepoRef,
565    ) -> Result<Option<NewRepo>, gunnar_core::Error> {
566        let namespace = path.namespace();
567        let namespace_exists = self.catalog.namespace(namespace)?.is_some();
568        if !namespace_exists && !self.auth.may_server(&principal.id, Level::Admin)? {
569            return Ok(None);
570        }
571        // The account admin's door — and a CLAN's member's (clans 2026-10-05,
572        // the rule `Repos.Create` keeps): the member creates PRIVATE
573        // repositories in the clan with the Write its membership gives, and
574        // the prospect below is private. An invited account that has not
575        // accepted is no member.
576        let admin = self
577            .auth
578            .may_namespace(&principal.id, namespace, Level::Admin)?;
579        let member = !admin
580            && namespace_exists
581            && self.auth.clan_role(&principal.id, namespace)?
582                == Some(gunnar_core::ClanRole::Member);
583        if !(admin || member) {
584            return Ok(None);
585        }
586        // **SHA-1, and only here.** `NewRepo::new` defaults to sha256, which is
587        // the right default for a repository somebody ASKS for: `repo create`
588        // takes a hash kind, and a caller that wants sha256 gets it. A
589        // first-push prospect is not asked for — it is inferred, before the
590        // client has said one word — and git's v1 receive-pack advertises the
591        // repository's object format BEFORE the client sends anything, so there
592        // is nothing to infer it from. The client then reads the capability and
593        // decides on its own.
594        //
595        // MEASURED on gunnar.rs, 2026-09-20, seeding a freshly re-imaged forge:
596        //
597        //     xtask: REFUSED [seed-push] nordisk/gunnar: protocol error:
598        //     local repository is Sha1 but the remote is Sha256;
599        //     git cannot push between object formats
600        //
601        // The push never reached the hash check this door's whole ordering was
602        // built around ("advertise the prospect, read the commands, check the
603        // hash, THEN write") — the client refused the advertisement. So the
604        // first-push door, as shipped, admitted only sha256 clients, and this
605        // estate has none: all four declared repositories are sha1, as is every
606        // clone of them and every repository git creates without being told
607        // otherwise.
608        //
609        // sha1 is what an un-negotiated client assumes, so it is the only
610        // prospect that can be right for a client that has not spoken yet. The
611        // hash check after the commands is unchanged and still has the final
612        // word; a sha256 pusher is still served by `repo create`, where the
613        // hash is a decision somebody made rather than one nobody could.
614        Ok(Some(NewRepo::new(path.clone()).hash_kind(HashKind::Sha1)))
615    }
616
617    /// **The first-repository door, the write**: the namespace, conjured if
618    /// it does not exist, and the catalog row — for a push that has sent its
619    /// commands and passed the hash check. Re-asks
620    /// [`Self::push_create_spec`]'s questions, so a grant revoked between the
621    /// advertisement and the pack is still a refusal; `Ok(None)` is that
622    /// refusal.
623    ///
624    /// The on-disk stores are NOT created here (this crate has no
625    /// filesystem): the transport creates them through `ServerStores`, and it
626    /// is the transport that makes the pair transactional — a store that
627    /// cannot be made rolls this row back, see `ServerStores::create_repo`.
628    ///
629    /// # Races
630    ///
631    /// Two admins pushing the same new name race on the catalog's
632    /// compare-and-swap. The loser of either CAS finds the thing it was
633    /// creating already there and proceeds — the repository exists, which is
634    /// what it wanted. `Err` is only ever a storage fault, and the caller
635    /// must report it as one ([`crate::server::DispatchError::Store`]), never
636    /// as not-found.
637    ///
638    /// Returns the repository and whether **this call** conjured the
639    /// namespace, so a caller that fails afterwards can take the namespace
640    /// back with the row and leave no trace at all.
641    pub fn admit_push_create(
642        &self,
643        principal: &Principal,
644        path: &RepoRef,
645    ) -> Result<Option<(Repo, bool)>, gunnar_core::Error> {
646        let Some(spec) = self.push_create_spec(principal, path)? else {
647            return Ok(None);
648        };
649        let namespace = path.namespace();
650        let mut conjured = false;
651        if self.catalog.namespace(namespace)?.is_none() {
652            match self.catalog.create_namespace(namespace) {
653                Ok(_) => conjured = true,
654                // Lost a race; it exists now, which is all that was wanted.
655                Err(gunnar_core::Error::NamespaceExists(_)) => {}
656                Err(err) => return Err(err),
657            }
658        }
659        let repo = match self.catalog.create(spec) {
660            Ok(repo) => repo,
661            // The other pusher won; serve the repository it created.
662            Err(gunnar_core::Error::RepoExists(_)) => self.catalog.open(path)?,
663            Err(err) => return Err(err),
664        };
665        self.account_event(
666            principal,
667            path.namespace(),
668            "repo.created",
669            &path.to_string(),
670            if conjured {
671                "by its first push (the namespace with it)"
672            } else {
673                "by its first push"
674            },
675            false,
676        );
677        tracing::warn!(
678            target: "gunnar::audit",
679            event = "repo.created_on_first_push",
680            principal = %principal.id,
681            repo = %path,
682            namespace_conjured = conjured,
683            "repository created by its first push; the creator's grant, not a new rule, \
684             is what authorised it"
685        );
686        Ok(Some((repo, conjured)))
687    }
688
689    /// Undo [`Self::admit_push_create`] after the store could not be made:
690    /// the row goes, and the namespace too if that call conjured it. Nothing
691    /// is left for `repo get` to find.
692    pub fn retract_push_create(
693        &self,
694        path: &RepoRef,
695        namespace_conjured: bool,
696    ) -> Result<(), gunnar_core::Error> {
697        match self.catalog.delete(path) {
698            Ok(_) | Err(gunnar_core::Error::NoSuchRepo(_)) => {}
699            Err(err) => return Err(err),
700        }
701        if namespace_conjured {
702            match self.catalog.delete_namespace(path.namespace(), false) {
703                Ok(_) | Err(gunnar_core::Error::NoSuchNamespace(_)) => {}
704                Err(err) => return Err(err),
705            }
706        }
707        Ok(())
708    }
709}
710
711impl std::fmt::Debug for Control {
712    /// By hand rather than derived, because the gates are `dyn` trait objects
713    /// with no `Debug` of their own — and what an operator wants to see of them
714    /// is which are wired and in what order, not their insides.
715    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
716        let gates: Vec<&'static str> = self.plan.iter().map(|g| g.name()).collect();
717        f.debug_struct("Control")
718            .field("auth", &self.auth)
719            .field("catalog", &self.catalog)
720            .field("writes_frozen", &self.writes_frozen())
721            .field("browse", &self.is_browse())
722            .field("anonymous", &self.anonymous)
723            .field("gates", &gates)
724            .finish()
725    }
726}