gunnar-core 3.0.0

gunnar's repository model, identity table, ACL and path sandbox. gunnar-ssh, gunnar-grpc and gunnar-cli all sit on it.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
//! The ACL model: principals, keys, namespaces, repositories, grants.
//!
//! Everything here is plain data with `serde` on it, because it is stored as a
//! JSON document in the object store (see [`crate::doc`]) and handed to gRPC
//! and to the SSH layer unchanged. No behaviour lives on these types beyond
//! validation and the level ordering that the ACL is built on.

use std::fmt;

use crate::error::{Error, Result};
use crate::sandbox::{check_component, RepoRef};

/// Milliseconds since the unix epoch. Signed, to match what gRPC carries.
pub type UnixMillis = i64;

/// The default branch a repository gets when none is named.
///
/// Re-exported, not declared: `gunnar_store` writes a fresh repository's `HEAD`
/// out of the same constant, and two crates each holding their own copy of this
/// name is what `point_head_at` exists to stop repeating.
pub use gunnar_store::DEFAULT_BRANCH;

/// Wall-clock now, in milliseconds since the unix epoch.
///
/// Clamped at zero rather than panicking on a clock before 1970. An appliance
/// with no RTC boots at the epoch, and refusing to serve because the clock is
/// wrong would be the wrong failure.
pub fn now_ms() -> UnixMillis {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_millis() as UnixMillis)
        .unwrap_or(0)
}

/// What kind of thing an identity is.
///
/// The distinction is not decorative: a `Deploy` key is expected to be
/// read-only and short-lived, and a UI that cannot tell a human from a CI
/// runner cannot show a useful key list.
#[derive(
    Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, serde::Serialize, serde::Deserialize,
)]
pub enum PrincipalKind {
    /// A person.
    User,
    /// A machine account: CI, a bot, another service.
    Machine,
    /// A deploy key: one repository, usually read-only.
    Deploy,
    /// **A group of principals** (punkt 9).
    ///
    /// A group is a principal like any other — it has an id, it can be the
    /// grantee of a [`Grant`] at any scope and level, and `grant`/`revoke`/`may`
    /// treat it no differently from a user (one implementation, no new grant type). What is
    /// special is only that it *holds no keys* and is *never authenticated as*:
    /// a group is a name that grants attach to, and the members of the group
    /// inherit those grants. Membership is an edge, tracked beside the identity
    /// table (`AuthTable::add_to_group`), not a field on the principal, so the
    /// same principal record serves a group and a user.
    Group,
    /// **Nobody.** The caller presented no key at all.
    ///
    /// There is exactly one principal of this kind, [`Principal::anonymous`].
    /// It is never stored in the identity table, it can never hold a key and it
    /// can never hold a grant. It exists so that "a stranger clones a public
    /// repository" is answered by [`AuthTable::may`](crate::AuthTable::may)
    /// like every other access question, rather than by a second code path
    /// that could disagree with the first (one implementation).
    Anonymous,
}

/// Someone, or something, that can authenticate.
///
/// **There are no OS users.** The server has no `/etc/passwd`, no home
/// directories and no `authorized_keys`; this table is the entire identity
/// system (the ACL model).
#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
pub struct Principal {
    /// Stable identifier, used as the key everywhere else. Same character
    /// rules as a namespace, so it can never be mistaken for a path.
    ///
    /// **It is also the account**: the namespace this principal intrinsically
    /// owns ([`AuthTable::owns_account`](crate::AuthTable::owns_account)), the
    /// key a gate is filed under, and what a policy file's
    /// `allowed_principals` names. Chosen once, at creation, and never moved.
    /// Everything that may change about a principal changes in `name`.
    pub id: String,
    /// Display name, for humans and for UIs.
    ///
    /// **Not the identity.** It defaults to the id and it may be changed
    /// ([`AuthTable::rename_principal`](crate::AuthTable::rename_principal))
    /// without touching a key, a grant, a membership, a gate or the namespace.
    /// It is unique on the table by construction — see the `names` index in
    /// [`AuthTable`](crate::AuthTable) — and may not equal another principal's
    /// id either, so a listing never shows two rows a human reads as one.
    pub name: String,
    /// What kind of identity this is.
    pub kind: PrincipalKind,
    /// When the row was created, by the table's clock, in unix milliseconds.
    ///
    /// Stamped by [`AuthTable::add_principal`](crate::AuthTable::add_principal)
    /// — one writer — and `0` for a row from before the field existed, which is
    /// what the wire reported for every principal until then. A caller that
    /// pins a principal by `(id, created)` can tell a delete-and-recreate under
    /// the same id from the row it first saw; the id alone cannot say, because
    /// an account slug is claimable again once its account is purged.
    #[serde(default)]
    pub created: UnixMillis,
}

/// The id of the one [`PrincipalKind::Anonymous`] principal.
///
/// **Reserved.** [`Principal::new`] and
/// [`AuthTable::add_principal`](crate::AuthTable::add_principal) both refuse
/// it, so no registered principal can ever be spelled this way and no public
/// key can ever be registered to it. That is what makes the rule in
/// [`AuthTable::may`](crate::AuthTable::may) a statement about a name that
/// cannot be claimed: *this id means "nobody", and nobody may read a public
/// repository and nothing else*.
pub const ANONYMOUS_PRINCIPAL_ID: &str = "anonymous";

impl Principal {
    /// Validate an id and build a principal.
    ///
    /// Refuses [`ANONYMOUS_PRINCIPAL_ID`]: the anonymous identity is not
    /// something an operator registers, and a registered principal wearing that
    /// name would be handed the public-read rule of
    /// [`AuthTable::may`](crate::AuthTable::may) for free.
    pub fn new(
        id: impl Into<String>,
        name: impl Into<String>,
        kind: PrincipalKind,
    ) -> Result<Self> {
        let id = id.into();
        check_component(&id, &id)?;
        if id == ANONYMOUS_PRINCIPAL_ID {
            return Err(Error::ReservedPrincipalId(id));
        }
        Ok(Principal {
            id,
            name: name.into(),
            kind,
            // The table stamps it on insert; a value chosen here would be the
            // caller's clock, and two clocks for one field is the twin.
            created: 0,
        })
    }

    /// **Nobody.** The principal an unauthenticated caller is served as.
    ///
    /// Built directly rather than through [`Principal::new`], which refuses the
    /// reserved id. There is no other constructor for it and no way to store
    /// one, so every value of this shape in the process came from here.
    pub fn anonymous() -> Self {
        Principal {
            id: ANONYMOUS_PRINCIPAL_ID.to_owned(),
            name: "anonymous".to_owned(),
            kind: PrincipalKind::Anonymous,
            created: 0,
        }
    }

    /// Is this the anonymous principal?
    ///
    /// **By id, not by `kind`.** The id is what
    /// [`AuthTable::may`](crate::AuthTable::may) is keyed on and what a grant
    /// would name; a `kind` that disagreed with the id would be the twinning
    /// this whole design avoids.
    pub fn is_anonymous(&self) -> bool {
        self.id == ANONYMOUS_PRINCIPAL_ID
    }
}

/// A registered public key.
///
/// The fingerprint is the primary key, because that is what an SSH handshake
/// gives the server: russh hands over the offered key, gunnar fingerprints it
/// and looks it up. The SSH *username* is decorative (the ACL model).
#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
pub struct PublicKey {
    /// OpenSSH's `SHA256:<base64>` fingerprint.
    pub fingerprint_sha256: String,
    /// The algorithm name as OpenSSH writes it: `ssh-ed25519`, `ssh-rsa`, …
    pub algo: String,
    /// Which principal this key authenticates.
    pub principal_id: String,
    /// When it was registered.
    pub added: UnixMillis,
    /// When it last authenticated, if ever.
    pub last_used: Option<UnixMillis>,
    /// When it stops being accepted, if ever.
    pub expires: Option<UnixMillis>,
    /// The `authorized_keys`-style line it was registered from.
    ///
    /// Kept because `gunnar-policy` needs the key material itself to verify
    /// `gpg.format=ssh` commit signatures (the pre-receive gate), and a fingerprint cannot
    /// verify a signature.
    pub openssh: String,
}

impl PublicKey {
    /// Is this key still valid at `now`?
    pub fn is_valid_at(&self, now: UnixMillis) -> bool {
        match self.expires {
            None => true,
            Some(expires) => now < expires,
        }
    }
}

/// **A web credential — a person's WebAuthn passkey** (Stage L, 2026-10-04).
///
/// A row in the identity table beside the person's SSH keys, so it lives and
/// dies with the principal, rides the same compare-and-swap, and reaches the
/// twin with the rest of `refs/gunnar/auth`. gunnar verifies a login's
/// assertion against `public_key_hex` itself: the console relays the browser's
/// answer and holds no credential of its own.
#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WebCredential {
    /// The authenticator's raw credential id, base64url without padding — the
    /// map key, and what a login names.
    pub id: String,
    /// The principal it logs in as. Only a USER may hold one.
    pub principal_id: String,
    /// What the person called it.
    pub label: String,
    /// The P-256 public key, SEC1 uncompressed (`04 || X || Y`), lowercase hex.
    pub public_key_hex: String,
    /// The authenticator's signature counter as of the last accepted login.
    pub sign_count: u32,
    /// When it was registered.
    pub created: UnixMillis,
    /// When it last logged in, if ever.
    #[serde(default)]
    pub last_used: Option<UnixMillis>,
    /// **The session keys its logins enrolled** (fingerprints), so revoking
    /// the passkey ends the sessions it opened. Pruned at each login to the
    /// keys still held and unexpired.
    #[serde(default)]
    pub sessions: Vec<String>,
}

/// **The operator's first invite, as the identity table keeps it** (Stage L,
/// 2026-10-04; IDENTITY-HOST 2026-10-27): the installer's break-glass token by
/// its hash only, for the operator's first passkey. Invites are the identity
/// core's (`nornir-identity`); this row is the one the core is FED (it reads it
/// as `sha256:<hex>` onto the operator and spends it there). The shape is the
/// table's from before the move, so a table written then still reads.
#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FirstInviteRow {
    /// The invite's public id — what a listing and a revocation name.
    pub id: String,
    /// The person whose passkey it enrols.
    pub principal_id: String,
    /// Lowercase hex SHA-256 of the secret. Never the secret.
    pub secret_hash: String,
    /// Who created it.
    pub created_by: String,
    /// When.
    pub created: UnixMillis,
    /// When it stops being redeemable.
    pub expires: UnixMillis,
    /// Free text from the inviter.
    #[serde(default)]
    pub note: String,
    /// The server-wide grant a redemption applies (the console's operator or
    /// admin), if any. Only a server admin's invite carries one.
    #[serde(default)]
    pub server_level: Option<Level>,
}

/// A registered **access token**: the credential smart HTTP authenticates with
///
///
/// # Why this is not a second identity model
///
/// It is a second *authentication* method for the identity model that already
/// exists. A token names a [`Principal`] and nothing else. It carries no grant,
/// no level and no scope, so every authorisation question it can produce is
/// still answered by [`AuthTable::may`](crate::AuthTable::may) against that
/// principal's grants — the same call, on the same table, that an SSH key
/// produces. Revoking the token removes a way in; it changes nobody's access.
///
/// # The secret is not here, and cannot be
///
/// [`Self::hash`] is the SHA-256 of the token secret, hex, and it is also the
/// key this record is stored under. That is deliberate on both counts:
///
/// * the stored table never holds a usable credential, so a leaked auth
///   document (it is a git blob, and every version of it is kept — see
///   [`doc`](crate::doc)) does not hand anybody a login;
/// * a lookup is *hash the presented secret, probe once*, which is O(1) and
///   does not walk the token list. A table keyed by anything else would either
///   scan on every HTTP request or need a second index to avoid it.
///
/// The hash is therefore also the token's **public id**: it is what
/// `gunnar admin token list` prints and what revocation names. Printing a hash
/// is safe; printing the secret again is not possible, which is the point.
#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
pub struct AccessToken {
    /// SHA-256 of the secret, lowercase hex. The primary key, and the id an
    /// operator sees.
    pub hash: String,
    /// Which principal this token authenticates.
    pub principal_id: String,
    /// An operator-supplied label: *which laptop, which CI job*. Never part of
    /// the credential.
    pub label: String,
    /// When it was created.
    pub created: UnixMillis,
    /// When it last authenticated, if ever.
    pub last_used: Option<UnixMillis>,
    /// When it stops being accepted, if ever.
    pub expires: Option<UnixMillis>,
}

impl AccessToken {
    /// Is this token still valid at `now`?
    ///
    /// Same shape as [`PublicKey::is_valid_at`] and deliberately so: expiry is
    /// one rule, and two credentials that expired by different arithmetic would
    /// be two rules (one implementation).
    pub fn is_valid_at(&self, now: UnixMillis) -> bool {
        match self.expires {
            None => true,
            Some(expires) => now < expires,
        }
    }
}

/// A tenant. First-class from day one (multi-tenancy), because adding it later would
/// mean migrating every stored path and every grant.
#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
pub struct Namespace {
    /// The name, possibly nested (`team/sub`).
    pub name: String,
    /// **The exploded read cache this account's repositories may hold**, in
    /// bytes — the quota, and the one number a plan is allowed to move.
    ///
    /// `None` is *"take the server's default"*, which is
    /// `min(10 % of /data, 8 GiB)` per repository. `Some(0)` is unlimited and
    /// is a deliberate answer, not an absent one — which is why this is an
    /// `Option` and not a `u64` with a magic value.
    ///
    /// What it buys, and what it does not: a bigger cache means more objects
    /// are already inflated, so reads are FAST; a smaller one means more reads
    /// re-inflate their own delta chain out of the pack (~0.5–2 ms an object),
    /// which is SLOW. It is never a refusal and never an error — an account at
    /// its budget serves every object it has, correctly, more slowly. Nothing
    /// in this field can lose data: every byte it bounds is re-derivable from
    /// the packs, which are not bounded by it.
    ///
    /// `serde(default)` so a control document written before the quota field loads
    /// unchanged and every existing namespace reads as "the default".
    #[serde(default)]
    pub explode_budget_bytes: Option<u64>,
    /// **The namespace is public** — an explicit setting of the account admin's
    /// (owner, 2026-10-03: "default for all accounts is that emitted events and
    /// namespaces are not public, such things has to be set").
    ///
    /// Private (`false`, the default) is what every namespace is until its
    /// account admin says otherwise: somebody outside the account learns of it
    /// only through a public repository in it, and a private namespace holding
    /// no public repository answers exactly as one that does not exist. Public:
    /// the namespace itself is shown to everyone, even while it holds no public
    /// repository. Either way a PRIVATE repository in it is never shown to an
    /// outsider — that is the repository's own visibility.
    ///
    /// Skipped when `false`, so a default namespace's stored row is byte for
    /// byte what it was before the field existed, and every older row reads as
    /// private.
    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
    pub public: bool,
    /// **The account's emitted events are public** — the account admin's
    /// explicit setting, `false` by default: until it is set, the events of an
    /// account are shown to nobody outside it. Skipped when `false`, as
    /// [`Namespace::public`].
    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
    pub events_public: bool,
}

/// Which hash a repository's objects are named by.
///
/// gunnar's own enum rather than [`gix_hash::Kind`] so the wire and storage
/// representations do not depend on a gix version's `serde` feature. sha256 is
/// the default for new repositories; sha1 stays fully
/// supported.
#[derive(
    Clone,
    Copy,
    PartialEq,
    Eq,
    PartialOrd,
    Ord,
    Hash,
    Debug,
    Default,
    serde::Serialize,
    serde::Deserialize,
)]
pub enum HashKind {
    /// Git's original hash.
    Sha1,
    /// The default for new repositories.
    #[default]
    Sha256,
}

impl From<HashKind> for gix_hash::Kind {
    fn from(kind: HashKind) -> Self {
        match kind {
            HashKind::Sha1 => gix_hash::Kind::Sha1,
            HashKind::Sha256 => gix_hash::Kind::Sha256,
        }
    }
}

/// `gix_hash::Kind` is `#[non_exhaustive]`, so the conversion back is fallible
/// by construction: a gix release that adds a third hash must not silently be
/// stored as one of gunnar's two.
impl TryFrom<gix_hash::Kind> for HashKind {
    type Error = Error;
    fn try_from(kind: gix_hash::Kind) -> Result<Self> {
        match kind {
            gix_hash::Kind::Sha1 => Ok(HashKind::Sha1),
            gix_hash::Kind::Sha256 => Ok(HashKind::Sha256),
            other => Err(Error::Other(anyhow::anyhow!(
                "gunnar does not know the hash kind {other}"
            ))),
        }
    }
}

impl std::fmt::Display for HashKind {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(match self {
            HashKind::Sha1 => "sha1",
            HashKind::Sha256 => "sha256",
        })
    }
}

/// Who may read a repository without an explicit grant.
///
/// > **⚠ The meaning of [`Visibility::Public`] changed on 2026-08-03.**
/// > It used to mean "any *authenticated* principal may read", because gunnar
/// > had no unauthenticated path at all and the field therefore honoured
/// > nothing a private repository did not already get from a grant. It now
/// > means what the word says: **readable by anyone, with no key**. An operator
/// > who marked a repository public under the old reading has published it
/// > under the new one. [`Visibility::Private`] is still the default and is
/// > unchanged, and an appliance that must never serve a stranger turns the
/// > anonymous path off at the listener
/// > (`SshConfig::without_anonymous_access`).
#[derive(
    Clone,
    Copy,
    PartialEq,
    Eq,
    PartialOrd,
    Ord,
    Hash,
    Debug,
    Default,
    serde::Serialize,
    serde::Deserialize,
)]
pub enum Visibility {
    /// Only principals with a grant. The default, and the only safe one.
    ///
    /// A private repository is **indistinguishable from one that does not
    /// exist** to a caller who may not read it. The SSH dispatch answers both
    /// with the same string, so a stranger cannot enumerate private
    /// repositories one guess at a time.
    #[default]
    Private,
    /// Readable by **anyone**, including a caller who presented no key at all
    /// That is what [`PrincipalKind::Anonymous`] is for.
    ///
    /// Read only, and only `git-upload-pack`. Writing still needs a grant, so
    /// [`Level::Write`] and [`Level::Admin`] are unaffected by this field.
    Public,
}

/// A repository, as the catalog holds it.
#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
pub struct Repo {
    /// The namespace it lives in, possibly nested.
    pub namespace: String,
    /// Its name, without any `.git` suffix.
    pub name: String,
    /// sha1 or sha256. Fixed at creation; a repository never changes hash.
    pub hash_kind: HashKind,
    /// Full ref name, e.g. `refs/heads/main`.
    pub default_branch: String,
    /// Who may read it without a grant.
    pub visibility: Visibility,
    /// When it was created.
    pub created: UnixMillis,
    /// Free-text description, for UIs.
    pub description: String,
    /// **Encrypted repository** (2026-10-05): the server refuses any push
    /// that would put plaintext on it — every new blob must be a
    /// gunnar-crypt envelope (the `encrypted` push gate). The server holds no
    /// key either way. Only the ACCOUNT admin sets it. Skipped when `false`, so
    /// every older row reads as not encrypted and is stored byte for byte as
    /// before.
    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
    pub encrypted: bool,
}

impl Repo {
    /// Its path, as the sandbox produces one.
    pub fn path(&self) -> RepoRef {
        RepoRef::new(&self.namespace, &self.name)
            .expect("a catalogued repo was built from a validated RepoRef")
    }

    /// Its catalog key, `<namespace>/<repo>`.
    pub fn key(&self) -> String {
        format!("{}/{}", self.namespace, self.name)
    }
}

/// The same string [`Repo::key`] returns, written straight into the formatter.
///
/// `key()` allocates; a `repo = %repo` span or log field does not. Both spell
/// `<namespace>/<repo>` and there is one implementation of that shape here, so
/// a log line and a catalog lookup can never disagree about what a repository
/// is called (one implementation). `RepoRef` already had exactly this pair.
impl fmt::Display for Repo {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}/{}", self.namespace, self.name)
    }
}

/// How much access a grant confers. Ordered: `Read < Write < Admin`, and the
/// ACL compares with `>=`, so an admin can push and a writer can fetch.
#[derive(
    Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, serde::Serialize, serde::Deserialize,
)]
pub enum Level {
    /// `git-upload-pack`: clone and fetch.
    Read,
    /// `git-receive-pack`: push. Implies [`Level::Read`].
    Write,
    /// Administer the repository, namespace or server. Implies
    /// [`Level::Write`].
    Admin,
}

impl Level {
    /// Does holding `self` satisfy a requirement of `needed`?
    ///
    /// The ordering is the whole point, and it is why the SSH dispatch compares
    /// levels instead of matching on which verb it got: adding a third verb
    /// (LFS over SSH) then cannot forget a case. `gunnar-ssh` had its own copy of
    /// this enum and this method; it now uses this one.
    pub fn permits(self, needed: Level) -> bool {
        self >= needed
    }
}

impl std::fmt::Display for Level {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(match self {
            Level::Read => "read",
            Level::Write => "write",
            Level::Admin => "admin",
        })
    }
}

/// What a grant applies to.
///
/// # Deviation from the brief, recorded
///
/// The brief writes `scope: Namespace|Repo`. [`Scope::Server`] is added,
/// because without it the bootstrap admin of the secret-store seam has nothing it is allowed to
/// do: it cannot create the first namespace, so a freshly bootstrapped server
/// would be reachable and useless. A strict superset: no namespace-scoped or
/// repo-scoped behaviour changes.
#[derive(
    Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, serde::Serialize, serde::Deserialize,
)]
pub enum Scope {
    /// The whole server. Only the bootstrap admin and whoever it appoints.
    Server,
    /// One namespace, matched **exactly**: a grant on `team` does *not* reach
    /// `team/sub`. Nested namespaces are separate tenants, and inheritance
    /// would be a privilege escalation that nobody wrote down.
    Namespace(String),
    /// One repository.
    Repo(RepoRef),
}

impl Scope {
    /// Does this scope cover `repo`?
    pub fn covers(&self, repo: &Repo) -> bool {
        match self {
            Scope::Server => true,
            Scope::Namespace(name) => name == &repo.namespace,
            Scope::Repo(path) => path.namespace() == repo.namespace && path.name() == repo.name,
        }
    }

    /// Does this scope cover the namespace `name`?
    pub fn covers_namespace(&self, name: &str) -> bool {
        match self {
            Scope::Server => true,
            Scope::Namespace(scoped) => scoped == name,
            Scope::Repo(_) => false,
        }
    }
}

impl std::fmt::Display for Scope {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Scope::Server => f.write_str("server"),
            Scope::Namespace(name) => write!(f, "namespace {name}"),
            Scope::Repo(path) => write!(f, "repo {path}"),
        }
    }
}

/// One row of the ACL: this principal has this much access to this scope.
#[derive(
    Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, serde::Serialize, serde::Deserialize,
)]
pub struct Grant {
    /// Which principal, by [`Principal::id`].
    pub principal: String,
    /// What it applies to.
    pub scope: Scope,
    /// How much.
    pub level: Level,
}

impl Grant {
    /// A grant on one repository.
    pub fn repo(principal: impl Into<String>, path: RepoRef, level: Level) -> Self {
        Grant {
            principal: principal.into(),
            scope: Scope::Repo(path),
            level,
        }
    }

    /// A grant on a whole namespace.
    pub fn namespace(
        principal: impl Into<String>,
        namespace: impl Into<String>,
        level: Level,
    ) -> Self {
        Grant {
            principal: principal.into(),
            scope: Scope::Namespace(namespace.into()),
            level,
        }
    }

    /// A grant on the whole server.
    pub fn server(principal: impl Into<String>, level: Level) -> Self {
        Grant {
            principal: principal.into(),
            scope: Scope::Server,
            level,
        }
    }
}

/// Check that `name` is a legal full ref name under `refs/heads/`.
///
/// Reuses `gunnar_store`'s validator rather than growing a second opinion about
/// what a ref name is (one implementation). That one already rejects `..`, control
/// characters and empty components.
///
/// It used to be `FullName::new`, which validated *and* produced a value type.
/// `FullName` went with `RefStore`; the rules did not — they are a property of
/// git, not of a backend, and one of them (`refs/heads/../../etc/passwd`) is a
/// path-traversal boundary. They live in `gunnar_store::refname` now, as a
/// free function over `&str`, which is the shape `GitOps` speaks.
pub(crate) fn check_branch(name: &str) -> Result<String> {
    if !name.starts_with("refs/heads/") {
        return Err(Error::BadBranchName(format!(
            "{name:?}: a default branch must be a full name under refs/heads/"
        )));
    }
    gunnar_store::refname::validate(name)
        .map_err(|err| Error::BadBranchName(format!("{name:?}: {err}")))?;
    Ok(name.to_owned())
}