Skip to main content

gunnar_core/
error.rs

1//! The one error type this crate reports through.
2
3use crate::model::Level;
4use crate::sandbox::{PathReject, RepoRef};
5
6/// The crate-wide result type.
7pub type Result<T, E = Error> = std::result::Result<T, E>;
8
9/// Everything the repository model, the identity table and the sandbox can
10/// refuse.
11///
12/// The variants are deliberately shaped so the SSH and gRPC layers can map them
13/// onto their own vocabularies without parsing strings: [`Error::InvalidPath`]
14/// carries a [`PathReject`], [`Error::Denied`] carries the level that was
15/// missing.
16#[derive(Debug, thiserror::Error)]
17#[non_exhaustive]
18pub enum Error {
19    /// The path sandbox refused an SSH command argument. This is the security
20    /// boundary of the ACL model; see [`crate::sandbox`].
21    #[error("refused repository path \"{input}\": {reason}")]
22    InvalidPath {
23        /// The offending input, escaped for safe logging.
24        input: String,
25        /// Which rule fired.
26        reason: PathReject,
27    },
28
29    /// A [`RepoRef`] would have resolved outside the repository root once
30    /// symlinks were followed. Only the `fs` backend can produce this.
31    #[error("path escape: {} {reason}", path.display())]
32    PathEscape {
33        /// The path that was being resolved.
34        path: std::path::PathBuf,
35        /// What was wrong with it.
36        reason: String,
37    },
38
39    /// No such repository in the catalog.
40    #[error("no such repository: {0}")]
41    NoSuchRepo(RepoRef),
42
43    /// A repository with this path is already in the catalog.
44    #[error("repository already exists: {0}")]
45    RepoExists(RepoRef),
46
47    /// No such namespace. Namespaces are created explicitly; a repository is
48    /// never allowed to conjure one, because a typo would otherwise become a
49    /// tenant.
50    #[error("no such namespace: {0}")]
51    NoSuchNamespace(String),
52
53    /// A namespace with this name is already in the catalog.
54    #[error("namespace already exists: {0}")]
55    NamespaceExists(String),
56
57    /// A namespace was deleted without `delete_repos` while it still held
58    /// repositories.
59    #[error("namespace {name} still holds {repos} repositories")]
60    NamespaceNotEmpty {
61        /// The namespace.
62        name: String,
63        /// How many repositories are in it.
64        repos: usize,
65    },
66
67    /// **The unauthenticated enrolment window has already been used.**
68    ///
69    /// It is shut permanently, and this is the answer whatever the identity
70    /// table now holds — including when every identity it created has since
71    /// been deleted. The record lives beside the table, not in it, precisely so
72    /// that this refusal cannot be undone by a delete.
73    #[error(
74        "this server was bootstrapped at {at} by principal \"{principal_id}\"; the \
75         unauthenticated enrolment window is closed permanently and deleting identities does \
76         not reopen it. Add users with `Access.AddPrincipal`, authenticated, as a gunnar-admin."
77    )]
78    AlreadyBootstrapped {
79        /// When it happened, in milliseconds since the unix epoch.
80        at: crate::model::UnixMillis,
81        /// Who it created.
82        principal_id: String,
83    },
84
85    /// Enrolment was attempted against a table that already holds identities
86    /// but carries no bootstrap record: a table written before the record
87    /// existed.
88    ///
89    /// A separate variant from [`Error::AlreadyBootstrapped`] so an operator
90    /// can tell "somebody already enrolled" from "this is an older data path",
91    /// and so a test can prove which of the two refusals fired.
92    #[error(
93        "this server already holds {principals} principals and {keys} keys, so it is not a \
94         fresh server and cannot be bootstrapped"
95    )]
96    AlreadyProvisioned {
97        /// How many principals are in the table.
98        principals: usize,
99        /// How many keys are in the table.
100        keys: usize,
101    },
102
103    /// No principal with this id.
104    #[error("no such principal: {0}")]
105    NoSuchPrincipal(String),
106
107    /// A principal with this id already exists.
108    #[error("principal already exists: {0}")]
109    PrincipalExists(String),
110
111    /// Another principal already carries this name — as its name, or as its
112    /// id.
113    ///
114    /// Refused so that a listing never shows two rows a human reads as one,
115    /// and so a name can never be mistaken for a different principal's id on
116    /// the wire. The id is the identity; the name is the label; the label is
117    /// still unique, by construction, in the same compare-and-swap as the row.
118    #[error("the name \"{name}\" is already taken by principal {principal_id}")]
119    NameExists {
120        /// The name that was asked for.
121        name: String,
122        /// Who holds it.
123        principal_id: String,
124    },
125
126    /// This principal exists but is not a [`Group`](crate::PrincipalKind::Group),
127    /// so it cannot have members.
128    ///
129    /// Membership is a property of groups alone: adding a member to a user or a
130    /// machine would create an edge the ACL never reads, which is worse than
131    /// refusing it, because it looks like access was granted when none was.
132    #[error("principal \"{0}\" is not a group; only a group can have members")]
133    NotAGroup(String),
134
135    /// The id is reserved by gunnar itself and may not be registered.
136    ///
137    /// Today there is exactly one:
138    /// [`ANONYMOUS_PRINCIPAL_ID`](crate::model::ANONYMOUS_PRINCIPAL_ID). A
139    /// principal registered under that name would inherit the anonymous
140    /// public-read rule in [`AuthTable::may`](crate::AuthTable::may), and,
141    /// because it *would* be in the table, would be handed a key and a grant
142    /// as well. Refusing the name is what keeps "anonymous holds nothing" a
143    /// property of the system rather than of the operator's care.
144    #[error(
145        "principal id \"{0}\" is reserved by gunnar and cannot be registered; \
146         it names the anonymous caller"
147    )]
148    ReservedPrincipalId(String),
149
150    /// This public key is already registered, possibly to another principal.
151    ///
152    /// Re-registering is refused rather than silently re-pointed: a key that
153    /// maps to two principals makes the SSH auth answer ambiguous, and the
154    /// ambiguity would be resolved by whichever came first in a map.
155    #[error("public key {fingerprint} is already registered to principal {principal_id}")]
156    KeyExists {
157        /// The fingerprint that is already known.
158        fingerprint: String,
159        /// Who holds it.
160        principal_id: String,
161    },
162
163    /// This access token secret is already registered.
164    ///
165    /// Refused for the reason [`Error::KeyExists`] gives, and for one more: two
166    /// principals sharing a secret means the *holder* of that secret cannot be
167    /// told which of them they are acting as, and revoking it for one revokes
168    /// it for both. Colliding by accident takes a 256-bit coincidence; getting
169    /// here means the same secret was registered twice on purpose.
170    #[error("this access token is already registered to principal {principal_id} (as {hash})")]
171    TokenExists {
172        /// The hash that is already known. **Never the secret.**
173        hash: String,
174        /// Who holds it.
175        principal_id: String,
176    },
177
178    /// This web credential id is already registered (Stage L).
179    #[error("web credential {id} is already registered to principal {principal_id}")]
180    CredentialExists {
181        /// The credential id, base64url.
182        id: String,
183        /// Who holds it.
184        principal_id: String,
185    },
186
187    /// No web credential with this id (Stage L).
188    #[error("no such web credential: {0}")]
189    NoSuchCredential(String),
190
191    /// **A web login whose signature counter did not increase** (Stage L): a
192    /// cloned authenticator, or a replay. Re-checked inside the update that
193    /// would have stored the new counter.
194    #[error(
195        "the signature counter of web credential {id} went from {stored} to {presented}: a \
196         cloned authenticator, or a replay"
197    )]
198    CounterRollback {
199        /// The credential id, base64url.
200        id: String,
201        /// The counter stored.
202        stored: u32,
203        /// The counter presented.
204        presented: u32,
205    },
206
207    /// **An invite that cannot be redeemed** (Stage L): unknown, already
208    /// used, expired, or for somebody else — one sentence for all four, so a
209    /// refusal says nothing about which secrets exist.
210    #[error(
211        "this invite is not valid: it is unknown, already used, expired, or for somebody else"
212    )]
213    InviteInvalid,
214
215    /// This invite id is already taken (Stage L).
216    #[error("invite {0} already exists")]
217    InviteExists(String),
218
219    /// **No human credential on a machine principal** (Stage L): a web
220    /// credential (a passkey) is a person's, and only a USER is a person.
221    #[error(
222        "principal {principal_id} is a {kind}, and a passkey is a person's: no human credential \
223         on a machine principal"
224    )]
225    NotAPerson {
226        /// The principal asked about.
227        principal_id: String,
228        /// What it is instead.
229        kind: String,
230    },
231
232    /// **A machine's credential never lands on a person** (2026-10-05): an
233    /// approved machine enrolment names a principal that exists and is not a
234    /// MACHINE.
235    #[error(
236        "principal {principal_id} is a {kind}, and an approved enrolment is a machine's: a \
237         machine's credential never lands on a person"
238    )]
239    NotAMachine {
240        /// The principal asked about.
241        principal_id: String,
242        /// What it is instead.
243        kind: String,
244    },
245
246    /// A public key could not be parsed, or is not in OpenSSH form.
247    #[error("not a usable OpenSSH public key: {0}")]
248    Key(String),
249
250    /// A fingerprint string was not in OpenSSH's `SHA256:<base64>` form.
251    #[error("not an OpenSSH sha256 fingerprint: {0}")]
252    BadFingerprint(String),
253
254    /// **This server already has an issuer** (the certificate-identity plan).
255    ///
256    /// Refused rather than silently ignored, and refused rather than replaced.
257    /// A second issuer is a second answer to *who may mint a user*, and the
258    /// failure mode of two is that nobody can say which one signed a given
259    /// client. Replacing the one that exists invalidates every certificate in
260    /// the field, so it is a different verb.
261    #[error(
262        "this server already has an issuer: {subject} ({fingerprint}). Creating a second one \
263         would mean two answers to \"who may mint a user\". Replacing it invalidates every \
264         certificate already issued, which is `gunnar issuer rotate`, not this"
265    )]
266    IssuerExists {
267        /// The live issuer's subject common name.
268        subject: String,
269        /// Its `SHA256:` fingerprint.
270        fingerprint: String,
271    },
272
273    /// Something asked this server to issue a certificate and it has no issuer.
274    #[error(
275        "this server has no certificate issuer. `gunnar issuer create` mints one, or `gunnar \
276         issuer import --cert C --key K` adopts one that already exists"
277    )]
278    NoIssuer,
279
280    /// Minting, importing or issuing failed.
281    ///
282    /// Its own variant rather than [`Error::Key`], which is about OpenSSH
283    /// public keys: an operator reading "not a usable OpenSSH public key" after
284    /// handing over an X.509 CSR would look in the wrong place.
285    #[error("the certificate issuer refused: {0}")]
286    Pki(String),
287
288    /// Access was refused by the ACL.
289    #[error("principal {principal_id} may not {level:?} {repo}")]
290    Denied {
291        /// Who asked.
292        principal_id: String,
293        /// What they asked for.
294        level: Level,
295        /// Which repository.
296        repo: RepoRef,
297    },
298
299    /// **The account is gated** (see [`crate::gate`]): locked out entirely, at
300    /// every level, until the gate is lifted.
301    ///
302    /// Its own variant and not an [`Error::Denied`] with a different message,
303    /// because the two ask different things of whoever reads them. `Denied`
304    /// means *get a grant*; this means *a grant will not help, the account is
305    /// shut*. A user handed the first when the second is true spends the
306    /// afternoon in the ACL.
307    #[error("the account {account} is locked ({reason}): {note}")]
308    Gated {
309        /// The account — a principal id, which is also its namespace.
310        account: String,
311        /// `administrative` or `purge`.
312        reason: String,
313        /// What the operator or the purge wrote, for the user to act on.
314        note: String,
315    },
316
317    /// A stored control document could not be decoded. The store is not
318    /// trusted to be a valid document store, so this is a real error and never
319    /// a silent empty result. An empty catalog and a corrupt catalog must not
320    /// look the same.
321    #[error("the control document at {ref_name} is unusable: {reason}")]
322    CorruptDocument {
323        /// Which document.
324        ref_name: String,
325        /// Why it could not be read.
326        reason: String,
327    },
328
329    /// A compare-and-swap update lost its race too many times in a row.
330    #[error("gave up updating {ref_name} after {attempts} contended attempts")]
331    Contended {
332        /// Which document.
333        ref_name: String,
334        /// How many times it was retried.
335        attempts: usize,
336    },
337
338    /// A default branch name is not a legal `refs/heads/...` name.
339    #[error("not a legal default branch name: {0}")]
340    BadBranchName(String),
341
342    /// A stored document was handed a value it will not keep.
343    ///
344    /// The empty policy document is the case this was added for: an empty
345    /// string reads back as *"a policy exists and configures nothing"*, which
346    /// is indistinguishable from a write that lost its content, so it is
347    /// refused and the caller is told which call removes a policy instead. It
348    /// is deliberately **not** `InvalidPath` — nothing about a path is wrong —
349    /// and deliberately not a `CorruptDocument`, which is about reading.
350    #[error("refused a {what}: {reason}")]
351    InvalidInput {
352        /// What was being stored.
353        what: &'static str,
354        /// Why it was refused, and what to do instead.
355        reason: String,
356    },
357
358    /// **Not enough room** (owner, 2026-10-04, "Add users"): the account's
359    /// room that is neither stored nor promised is less than what was asked.
360    /// Names how much is missing, because that is what the account admin has
361    /// to act on.
362    #[error(
363        "{account} has {} of room unused, and {} is asked: {} short. Add room to the account, or \
364         ask for fewer users or less room per user",
365        human_bytes(*unused),
366        human_bytes(*need),
367        human_bytes(*short)
368    )]
369    RoomShort {
370        /// The account.
371        account: String,
372        /// What the enrolment asked of it.
373        need: u64,
374        /// What it holds that is neither stored nor promised.
375        unused: u64,
376        /// What a sale would have to add for the ask to fit: more than
377        /// `need - unused` when the account already stores or promises more
378        /// than its cap.
379        short: u64,
380    },
381
382    /// **A single sign-on the identity table will not commit** (company SSO,
383    /// 2026-10-05): a subject bound elsewhere, a deactivated directory user,
384    /// an account with no identity provider, no free id for a new employee.
385    /// The sentence says which; it never carries a token or a secret.
386    #[error("single sign-on refused: {0}")]
387    SsoRefused(String),
388
389    /// **No open seat**: a seat claim on an account whose seats are all
390    /// filled, or that has none.
391    #[error("{0} has no open seat: every seat it holds is filled. Its admin adds users first")]
392    NoOpenSeat(String),
393
394    /// The storage layer failed.
395    #[error(transparent)]
396    Store(#[from] gunnar_store::Error),
397
398    /// **The control store failed** — the redb database the catalog and the
399    /// identity table live in (the certificate-identity plan, [`crate::control`]).
400    ///
401    /// Its own variant rather than an [`Error::Other`], because it is not the
402    /// same fault as [`Error::Store`]: that one is a *repository's* backend,
403    /// and an operator whose control plane is unreadable has a different
404    /// problem from one whose repository is. Never produced for "the document
405    /// has never been written", which is `Ok(None)`.
406    #[error("the control store failed: {0}")]
407    ControlStore(String),
408
409    /// **The control store is OFF THE STRIPE** — suspended by the runtime
410    /// data-set growth (`ControlDb::suspend`): `/data` is unmounted, every
411    /// read is answered from the RAM copy taken before it went, and every
412    /// WRITE is refused with this until the set is mounted and the store
413    /// resumed. `DATA-SET-GROWTH-FLOW.md` G4/T2/D7: refuse by name, never
414    /// queue; the caller retries once the growth is done. Maps to
415    /// `UNAVAILABLE` on the control plane, never to `INTERNAL`.
416    #[error(
417        "the control store is off the stripe while the data set grows ({why}); writes are refused \
418         until /data is mounted again — retry in a few minutes"
419    )]
420    OffStripe {
421        /// What the growth is doing right now.
422        why: String,
423    },
424
425    /// **This box's transaction log FOLLOWS another's** — it is being restored (or it is a
426    /// twin): it originates no write until the restore is whole. Not a fault of the server and
427    /// not the caller's: try again (the control plane answers UNAVAILABLE).
428    #[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")]
429    LogFollows,
430
431    /// A control document could not be encoded or decoded as JSON.
432    #[error(transparent)]
433    Json(#[from] serde_json::Error),
434
435    /// Anything else.
436    #[error(transparent)]
437    Other(#[from] anyhow::Error),
438
439    /// **A clan verb refused** (owner, 2026-10-05: "ordinary accounts can
440    /// group with other in a clan, where group created repos are administered
441    /// by the group admin"). `refusal` says which kind of no it is — the clan
442    /// is not there, the caller is not its admin, the rule (room, the floor,
443    /// the last admin, an employee) — and `reason` is the whole sentence the
444    /// person reads.
445    #[error("{reason}")]
446    Clan {
447        /// The clan (its namespace).
448        clan: String,
449        /// Which kind of refusal.
450        refusal: crate::auth::ClanRefusal,
451        /// The sentence.
452        reason: String,
453    },
454}
455
456impl Error {
457    /// `true` if this is the sandbox refusing an input, rather than a fault.
458    ///
459    /// The SSH layer answers these with a channel failure and a log line, and
460    /// must not confuse them with a storage error.
461    pub fn is_path_rejection(&self) -> bool {
462        matches!(self, Error::InvalidPath { .. } | Error::PathEscape { .. })
463    }
464
465    /// The [`PathReject`] behind an [`Error::InvalidPath`], for tests that
466    /// assert *which* rule fired.
467    pub fn path_reject(&self) -> Option<PathReject> {
468        match self {
469            Error::InvalidPath { reason, .. } => Some(*reason),
470            _ => None,
471        }
472    }
473
474    /// `true` if this is an access refusal.
475    ///
476    /// **Deliberately false for [`Error::Gated`].** The SSH and gRPC layers
477    /// route on these two, and folding a lockout into "denied" is how a user
478    /// gets sent to look for a grant that would change nothing.
479    pub fn is_denied(&self) -> bool {
480        matches!(self, Error::Denied { .. })
481    }
482
483    /// `true` if the account is locked out (see [`crate::gate`]).
484    pub fn is_gated(&self) -> bool {
485        matches!(self, Error::Gated { .. })
486    }
487}
488
489impl From<ssh_key::Error> for Error {
490    fn from(err: ssh_key::Error) -> Self {
491        Error::Key(err.to_string())
492    }
493}
494
495/// Bytes as a person reads them: `10 GiB`, `1.5 GiB`, `512 MiB`, `300 bytes`.
496pub fn human_bytes(bytes: u64) -> String {
497    const KIB: u64 = 1 << 10;
498    const MIB: u64 = 1 << 20;
499    const GIB: u64 = 1 << 30;
500    const TIB: u64 = 1 << 40;
501    let (unit, name) = match bytes {
502        b if b >= TIB => (TIB, "TiB"),
503        b if b >= GIB => (GIB, "GiB"),
504        b if b >= MIB => (MIB, "MiB"),
505        b if b >= KIB => (KIB, "KiB"),
506        _ => return format!("{bytes} bytes"),
507    };
508    let tenths = (u128::from(bytes) * 10 + u128::from(unit) / 2) / u128::from(unit);
509    if tenths % 10 == 0 {
510        format!("{} {name}", tenths / 10)
511    } else {
512        format!("{}.{} {name}", tenths / 10, tenths % 10)
513    }
514}