gunnar-core 2.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
//! The one error type this crate reports through.

use crate::model::Level;
use crate::sandbox::{PathReject, RepoRef};

/// The crate-wide result type.
pub type Result<T, E = Error> = std::result::Result<T, E>;

/// Everything the repository model, the identity table and the sandbox can
/// refuse.
///
/// The variants are deliberately shaped so the SSH and gRPC layers can map them
/// onto their own vocabularies without parsing strings: [`Error::InvalidPath`]
/// carries a [`PathReject`], [`Error::Denied`] carries the level that was
/// missing.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
    /// The path sandbox refused an SSH command argument. This is the security
    /// boundary of the ACL model; see [`crate::sandbox`].
    #[error("refused repository path \"{input}\": {reason}")]
    InvalidPath {
        /// The offending input, escaped for safe logging.
        input: String,
        /// Which rule fired.
        reason: PathReject,
    },

    /// A [`RepoRef`] would have resolved outside the repository root once
    /// symlinks were followed. Only the `fs` backend can produce this.
    #[error("path escape: {} {reason}", path.display())]
    PathEscape {
        /// The path that was being resolved.
        path: std::path::PathBuf,
        /// What was wrong with it.
        reason: String,
    },

    /// No such repository in the catalog.
    #[error("no such repository: {0}")]
    NoSuchRepo(RepoRef),

    /// A repository with this path is already in the catalog.
    #[error("repository already exists: {0}")]
    RepoExists(RepoRef),

    /// No such namespace. Namespaces are created explicitly; a repository is
    /// never allowed to conjure one, because a typo would otherwise become a
    /// tenant.
    #[error("no such namespace: {0}")]
    NoSuchNamespace(String),

    /// A namespace with this name is already in the catalog.
    #[error("namespace already exists: {0}")]
    NamespaceExists(String),

    /// A namespace was deleted without `delete_repos` while it still held
    /// repositories.
    #[error("namespace {name} still holds {repos} repositories")]
    NamespaceNotEmpty {
        /// The namespace.
        name: String,
        /// How many repositories are in it.
        repos: usize,
    },

    /// **The unauthenticated enrolment window has already been used.**
    ///
    /// It is shut permanently, and this is the answer whatever the identity
    /// table now holds — including when every identity it created has since
    /// been deleted. The record lives beside the table, not in it, precisely so
    /// that this refusal cannot be undone by a delete.
    #[error(
        "this server was bootstrapped at {at} by principal \"{principal_id}\"; the \
         unauthenticated enrolment window is closed permanently and deleting identities does \
         not reopen it. Add users with `Access.AddPrincipal`, authenticated, as a gunnar-admin."
    )]
    AlreadyBootstrapped {
        /// When it happened, in milliseconds since the unix epoch.
        at: crate::model::UnixMillis,
        /// Who it created.
        principal_id: String,
    },

    /// Enrolment was attempted against a table that already holds identities
    /// but carries no bootstrap record: a table written before the record
    /// existed.
    ///
    /// A separate variant from [`Error::AlreadyBootstrapped`] so an operator
    /// can tell "somebody already enrolled" from "this is an older data path",
    /// and so a test can prove which of the two refusals fired.
    #[error(
        "this server already holds {principals} principals and {keys} keys, so it is not a \
         fresh server and cannot be bootstrapped"
    )]
    AlreadyProvisioned {
        /// How many principals are in the table.
        principals: usize,
        /// How many keys are in the table.
        keys: usize,
    },

    /// No principal with this id.
    #[error("no such principal: {0}")]
    NoSuchPrincipal(String),

    /// A principal with this id already exists.
    #[error("principal already exists: {0}")]
    PrincipalExists(String),

    /// Another principal already carries this name — as its name, or as its
    /// id.
    ///
    /// Refused so that a listing never shows two rows a human reads as one,
    /// and so a name can never be mistaken for a different principal's id on
    /// the wire. The id is the identity; the name is the label; the label is
    /// still unique, by construction, in the same compare-and-swap as the row.
    #[error("the name \"{name}\" is already taken by principal {principal_id}")]
    NameExists {
        /// The name that was asked for.
        name: String,
        /// Who holds it.
        principal_id: String,
    },

    /// This principal exists but is not a [`Group`](crate::PrincipalKind::Group),
    /// so it cannot have members.
    ///
    /// Membership is a property of groups alone: adding a member to a user or a
    /// machine would create an edge the ACL never reads, which is worse than
    /// refusing it, because it looks like access was granted when none was.
    #[error("principal \"{0}\" is not a group; only a group can have members")]
    NotAGroup(String),

    /// The id is reserved by gunnar itself and may not be registered.
    ///
    /// Today there is exactly one:
    /// [`ANONYMOUS_PRINCIPAL_ID`](crate::model::ANONYMOUS_PRINCIPAL_ID). A
    /// principal registered under that name would inherit the anonymous
    /// public-read rule in [`AuthTable::may`](crate::AuthTable::may), and,
    /// because it *would* be in the table, would be handed a key and a grant
    /// as well. Refusing the name is what keeps "anonymous holds nothing" a
    /// property of the system rather than of the operator's care.
    #[error(
        "principal id \"{0}\" is reserved by gunnar and cannot be registered; \
         it names the anonymous caller"
    )]
    ReservedPrincipalId(String),

    /// This public key is already registered, possibly to another principal.
    ///
    /// Re-registering is refused rather than silently re-pointed: a key that
    /// maps to two principals makes the SSH auth answer ambiguous, and the
    /// ambiguity would be resolved by whichever came first in a map.
    #[error("public key {fingerprint} is already registered to principal {principal_id}")]
    KeyExists {
        /// The fingerprint that is already known.
        fingerprint: String,
        /// Who holds it.
        principal_id: String,
    },

    /// This access token secret is already registered.
    ///
    /// Refused for the reason [`Error::KeyExists`] gives, and for one more: two
    /// principals sharing a secret means the *holder* of that secret cannot be
    /// told which of them they are acting as, and revoking it for one revokes
    /// it for both. Colliding by accident takes a 256-bit coincidence; getting
    /// here means the same secret was registered twice on purpose.
    #[error("this access token is already registered to principal {principal_id} (as {hash})")]
    TokenExists {
        /// The hash that is already known. **Never the secret.**
        hash: String,
        /// Who holds it.
        principal_id: String,
    },

    /// This web credential id is already registered (Stage L).
    #[error("web credential {id} is already registered to principal {principal_id}")]
    CredentialExists {
        /// The credential id, base64url.
        id: String,
        /// Who holds it.
        principal_id: String,
    },

    /// No web credential with this id (Stage L).
    #[error("no such web credential: {0}")]
    NoSuchCredential(String),

    /// **A web login whose signature counter did not increase** (Stage L): a
    /// cloned authenticator, or a replay. Re-checked inside the update that
    /// would have stored the new counter.
    #[error(
        "the signature counter of web credential {id} went from {stored} to {presented}: a \
         cloned authenticator, or a replay"
    )]
    CounterRollback {
        /// The credential id, base64url.
        id: String,
        /// The counter stored.
        stored: u32,
        /// The counter presented.
        presented: u32,
    },

    /// **An invite that cannot be redeemed** (Stage L): unknown, already
    /// used, expired, or for somebody else — one sentence for all four, so a
    /// refusal says nothing about which secrets exist.
    #[error("this invite is not valid: it is unknown, already used, expired, or for somebody else")]
    InviteInvalid,

    /// This invite id is already taken (Stage L).
    #[error("invite {0} already exists")]
    InviteExists(String),

    /// **No human credential on a machine principal** (Stage L): a web
    /// credential (a passkey) is a person's, and only a USER is a person.
    #[error(
        "principal {principal_id} is a {kind}, and a passkey is a person's: no human credential \
         on a machine principal"
    )]
    NotAPerson {
        /// The principal asked about.
        principal_id: String,
        /// What it is instead.
        kind: String,
    },

    /// **A machine's credential never lands on a person** (2026-10-05): an
    /// approved machine enrolment names a principal that exists and is not a
    /// MACHINE.
    #[error(
        "principal {principal_id} is a {kind}, and an approved enrolment is a machine's: a \
         machine's credential never lands on a person"
    )]
    NotAMachine {
        /// The principal asked about.
        principal_id: String,
        /// What it is instead.
        kind: String,
    },

    /// A public key could not be parsed, or is not in OpenSSH form.
    #[error("not a usable OpenSSH public key: {0}")]
    Key(String),

    /// A fingerprint string was not in OpenSSH's `SHA256:<base64>` form.
    #[error("not an OpenSSH sha256 fingerprint: {0}")]
    BadFingerprint(String),

    /// **This server already has an issuer** (the certificate-identity plan).
    ///
    /// Refused rather than silently ignored, and refused rather than replaced.
    /// A second issuer is a second answer to *who may mint a user*, and the
    /// failure mode of two is that nobody can say which one signed a given
    /// client. Replacing the one that exists invalidates every certificate in
    /// the field, so it is a different verb.
    #[error(
        "this server already has an issuer: {subject} ({fingerprint}). Creating a second one \
         would mean two answers to \"who may mint a user\". Replacing it invalidates every \
         certificate already issued, which is `gunnar issuer rotate`, not this"
    )]
    IssuerExists {
        /// The live issuer's subject common name.
        subject: String,
        /// Its `SHA256:` fingerprint.
        fingerprint: String,
    },

    /// Something asked this server to issue a certificate and it has no issuer.
    #[error(
        "this server has no certificate issuer. `gunnar issuer create` mints one, or `gunnar \
         issuer import --cert C --key K` adopts one that already exists"
    )]
    NoIssuer,

    /// Minting, importing or issuing failed.
    ///
    /// Its own variant rather than [`Error::Key`], which is about OpenSSH
    /// public keys: an operator reading "not a usable OpenSSH public key" after
    /// handing over an X.509 CSR would look in the wrong place.
    #[error("the certificate issuer refused: {0}")]
    Pki(String),

    /// Access was refused by the ACL.
    #[error("principal {principal_id} may not {level:?} {repo}")]
    Denied {
        /// Who asked.
        principal_id: String,
        /// What they asked for.
        level: Level,
        /// Which repository.
        repo: RepoRef,
    },

    /// **The account is gated** (see [`crate::gate`]): locked out entirely, at
    /// every level, until the gate is lifted.
    ///
    /// Its own variant and not an [`Error::Denied`] with a different message,
    /// because the two ask different things of whoever reads them. `Denied`
    /// means *get a grant*; this means *a grant will not help, the account is
    /// shut*. A user handed the first when the second is true spends the
    /// afternoon in the ACL.
    #[error("the account {account} is locked ({reason}): {note}")]
    Gated {
        /// The account — a principal id, which is also its namespace.
        account: String,
        /// `administrative` or `purge`.
        reason: String,
        /// What the operator or the purge wrote, for the user to act on.
        note: String,
    },

    /// A stored control document could not be decoded. The store is not
    /// trusted to be a valid document store, so this is a real error and never
    /// a silent empty result. An empty catalog and a corrupt catalog must not
    /// look the same.
    #[error("the control document at {ref_name} is unusable: {reason}")]
    CorruptDocument {
        /// Which document.
        ref_name: String,
        /// Why it could not be read.
        reason: String,
    },

    /// A compare-and-swap update lost its race too many times in a row.
    #[error("gave up updating {ref_name} after {attempts} contended attempts")]
    Contended {
        /// Which document.
        ref_name: String,
        /// How many times it was retried.
        attempts: usize,
    },

    /// A default branch name is not a legal `refs/heads/...` name.
    #[error("not a legal default branch name: {0}")]
    BadBranchName(String),

    /// A stored document was handed a value it will not keep.
    ///
    /// The empty policy document is the case this was added for: an empty
    /// string reads back as *"a policy exists and configures nothing"*, which
    /// is indistinguishable from a write that lost its content, so it is
    /// refused and the caller is told which call removes a policy instead. It
    /// is deliberately **not** `InvalidPath` — nothing about a path is wrong —
    /// and deliberately not a `CorruptDocument`, which is about reading.
    #[error("refused a {what}: {reason}")]
    InvalidInput {
        /// What was being stored.
        what: &'static str,
        /// Why it was refused, and what to do instead.
        reason: String,
    },

    /// **Not enough room** (owner, 2026-10-04, "Add users"): the account's
    /// room that is neither stored nor promised is less than what was asked.
    /// Names how much is missing, because that is what the account admin has
    /// to act on.
    #[error(
        "{account} has {} of room unused, and {} is asked: {} short. Add room to the account, or \
         ask for fewer users or less room per user",
        human_bytes(*unused),
        human_bytes(*need),
        human_bytes(*short)
    )]
    RoomShort {
        /// The account.
        account: String,
        /// What the enrolment asked of it.
        need: u64,
        /// What it holds that is neither stored nor promised.
        unused: u64,
        /// What a sale would have to add for the ask to fit: more than
        /// `need - unused` when the account already stores or promises more
        /// than its cap.
        short: u64,
    },

    /// **A single sign-on the identity table will not commit** (company SSO,
    /// 2026-10-05): a subject bound elsewhere, a deactivated directory user,
    /// an account with no identity provider, no free id for a new employee.
    /// The sentence says which; it never carries a token or a secret.
    #[error("single sign-on refused: {0}")]
    SsoRefused(String),

    /// **No open seat**: a seat claim on an account whose seats are all
    /// filled, or that has none.
    #[error("{0} has no open seat: every seat it holds is filled. Its admin adds users first")]
    NoOpenSeat(String),

    /// The storage layer failed.
    #[error(transparent)]
    Store(#[from] gunnar_store::Error),

    /// **The control store failed** — the redb database the catalog and the
    /// identity table live in (the certificate-identity plan, [`crate::control`]).
    ///
    /// Its own variant rather than an [`Error::Other`], because it is not the
    /// same fault as [`Error::Store`]: that one is a *repository's* backend,
    /// and an operator whose control plane is unreadable has a different
    /// problem from one whose repository is. Never produced for "the document
    /// has never been written", which is `Ok(None)`.
    #[error("the control store failed: {0}")]
    ControlStore(String),

    /// **The control store is OFF THE STRIPE** — suspended by the runtime
    /// data-set growth (`ControlDb::suspend`): `/data` is unmounted, every
    /// read is answered from the RAM copy taken before it went, and every
    /// WRITE is refused with this until the set is mounted and the store
    /// resumed. `DATA-SET-GROWTH-FLOW.md` G4/T2/D7: refuse by name, never
    /// queue; the caller retries once the growth is done. Maps to
    /// `UNAVAILABLE` on the control plane, never to `INTERNAL`.
    #[error(
        "the control store is off the stripe while the data set grows ({why}); writes are refused \
         until /data is mounted again — retry in a few minutes"
    )]
    OffStripe {
        /// What the growth is doing right now.
        why: String,
    },

    /// **This box's transaction log FOLLOWS another's** — it is being restored (or it is a
    /// twin): it originates no write until the restore is whole. Not a fault of the server and
    /// not the caller's: try again (the control plane answers UNAVAILABLE).
    #[error("this box is being restored (or is a twin): its transaction log follows the twin's and it takes no write until the restore is whole — try again")]
    LogFollows,

    /// A control document could not be encoded or decoded as JSON.
    #[error(transparent)]
    Json(#[from] serde_json::Error),

    /// Anything else.
    #[error(transparent)]
    Other(#[from] anyhow::Error),

    /// **A clan verb refused** (owner, 2026-10-05: "ordinary accounts can
    /// group with other in a clan, where group created repos are administered
    /// by the group admin"). `refusal` says which kind of no it is — the clan
    /// is not there, the caller is not its admin, the rule (room, the floor,
    /// the last admin, an employee) — and `reason` is the whole sentence the
    /// person reads.
    #[error("{reason}")]
    Clan {
        /// The clan (its namespace).
        clan: String,
        /// Which kind of refusal.
        refusal: crate::auth::ClanRefusal,
        /// The sentence.
        reason: String,
    },
}

impl Error {
    /// `true` if this is the sandbox refusing an input, rather than a fault.
    ///
    /// The SSH layer answers these with a channel failure and a log line, and
    /// must not confuse them with a storage error.
    pub fn is_path_rejection(&self) -> bool {
        matches!(self, Error::InvalidPath { .. } | Error::PathEscape { .. })
    }

    /// The [`PathReject`] behind an [`Error::InvalidPath`], for tests that
    /// assert *which* rule fired.
    pub fn path_reject(&self) -> Option<PathReject> {
        match self {
            Error::InvalidPath { reason, .. } => Some(*reason),
            _ => None,
        }
    }

    /// `true` if this is an access refusal.
    ///
    /// **Deliberately false for [`Error::Gated`].** The SSH and gRPC layers
    /// route on these two, and folding a lockout into "denied" is how a user
    /// gets sent to look for a grant that would change nothing.
    pub fn is_denied(&self) -> bool {
        matches!(self, Error::Denied { .. })
    }

    /// `true` if the account is locked out (see [`crate::gate`]).
    pub fn is_gated(&self) -> bool {
        matches!(self, Error::Gated { .. })
    }
}

impl From<ssh_key::Error> for Error {
    fn from(err: ssh_key::Error) -> Self {
        Error::Key(err.to_string())
    }
}


/// Bytes as a person reads them: `10 GiB`, `1.5 GiB`, `512 MiB`, `300 bytes`.
pub fn human_bytes(bytes: u64) -> String {
    const KIB: u64 = 1 << 10;
    const MIB: u64 = 1 << 20;
    const GIB: u64 = 1 << 30;
    const TIB: u64 = 1 << 40;
    let (unit, name) = match bytes {
        b if b >= TIB => (TIB, "TiB"),
        b if b >= GIB => (GIB, "GiB"),
        b if b >= MIB => (MIB, "MiB"),
        b if b >= KIB => (KIB, "KiB"),
        _ => return format!("{bytes} bytes"),
    };
    let tenths = (u128::from(bytes) * 10 + u128::from(unit) / 2) / u128::from(unit);
    if tenths % 10 == 0 {
        format!("{} {name}", tenths / 10)
    } else {
        format!("{}.{} {name}", tenths / 10, tenths % 10)
    }
}