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("this invite is not valid: it is unknown, already used, expired, or for somebody else")]
211    InviteInvalid,
212
213    /// This invite id is already taken (Stage L).
214    #[error("invite {0} already exists")]
215    InviteExists(String),
216
217    /// **No human credential on a machine principal** (Stage L): a web
218    /// credential (a passkey) is a person's, and only a USER is a person.
219    #[error(
220        "principal {principal_id} is a {kind}, and a passkey is a person's: no human credential \
221         on a machine principal"
222    )]
223    NotAPerson {
224        /// The principal asked about.
225        principal_id: String,
226        /// What it is instead.
227        kind: String,
228    },
229
230    /// **A machine's credential never lands on a person** (2026-10-05): an
231    /// approved machine enrolment names a principal that exists and is not a
232    /// MACHINE.
233    #[error(
234        "principal {principal_id} is a {kind}, and an approved enrolment is a machine's: a \
235         machine's credential never lands on a person"
236    )]
237    NotAMachine {
238        /// The principal asked about.
239        principal_id: String,
240        /// What it is instead.
241        kind: String,
242    },
243
244    /// A public key could not be parsed, or is not in OpenSSH form.
245    #[error("not a usable OpenSSH public key: {0}")]
246    Key(String),
247
248    /// A fingerprint string was not in OpenSSH's `SHA256:<base64>` form.
249    #[error("not an OpenSSH sha256 fingerprint: {0}")]
250    BadFingerprint(String),
251
252    /// **This server already has an issuer** (the certificate-identity plan).
253    ///
254    /// Refused rather than silently ignored, and refused rather than replaced.
255    /// A second issuer is a second answer to *who may mint a user*, and the
256    /// failure mode of two is that nobody can say which one signed a given
257    /// client. Replacing the one that exists invalidates every certificate in
258    /// the field, so it is a different verb.
259    #[error(
260        "this server already has an issuer: {subject} ({fingerprint}). Creating a second one \
261         would mean two answers to \"who may mint a user\". Replacing it invalidates every \
262         certificate already issued, which is `gunnar issuer rotate`, not this"
263    )]
264    IssuerExists {
265        /// The live issuer's subject common name.
266        subject: String,
267        /// Its `SHA256:` fingerprint.
268        fingerprint: String,
269    },
270
271    /// Something asked this server to issue a certificate and it has no issuer.
272    #[error(
273        "this server has no certificate issuer. `gunnar issuer create` mints one, or `gunnar \
274         issuer import --cert C --key K` adopts one that already exists"
275    )]
276    NoIssuer,
277
278    /// Minting, importing or issuing failed.
279    ///
280    /// Its own variant rather than [`Error::Key`], which is about OpenSSH
281    /// public keys: an operator reading "not a usable OpenSSH public key" after
282    /// handing over an X.509 CSR would look in the wrong place.
283    #[error("the certificate issuer refused: {0}")]
284    Pki(String),
285
286    /// Access was refused by the ACL.
287    #[error("principal {principal_id} may not {level:?} {repo}")]
288    Denied {
289        /// Who asked.
290        principal_id: String,
291        /// What they asked for.
292        level: Level,
293        /// Which repository.
294        repo: RepoRef,
295    },
296
297    /// **The account is gated** (see [`crate::gate`]): locked out entirely, at
298    /// every level, until the gate is lifted.
299    ///
300    /// Its own variant and not an [`Error::Denied`] with a different message,
301    /// because the two ask different things of whoever reads them. `Denied`
302    /// means *get a grant*; this means *a grant will not help, the account is
303    /// shut*. A user handed the first when the second is true spends the
304    /// afternoon in the ACL.
305    #[error("the account {account} is locked ({reason}): {note}")]
306    Gated {
307        /// The account — a principal id, which is also its namespace.
308        account: String,
309        /// `administrative` or `purge`.
310        reason: String,
311        /// What the operator or the purge wrote, for the user to act on.
312        note: String,
313    },
314
315    /// A stored control document could not be decoded. The store is not
316    /// trusted to be a valid document store, so this is a real error and never
317    /// a silent empty result. An empty catalog and a corrupt catalog must not
318    /// look the same.
319    #[error("the control document at {ref_name} is unusable: {reason}")]
320    CorruptDocument {
321        /// Which document.
322        ref_name: String,
323        /// Why it could not be read.
324        reason: String,
325    },
326
327    /// A compare-and-swap update lost its race too many times in a row.
328    #[error("gave up updating {ref_name} after {attempts} contended attempts")]
329    Contended {
330        /// Which document.
331        ref_name: String,
332        /// How many times it was retried.
333        attempts: usize,
334    },
335
336    /// A default branch name is not a legal `refs/heads/...` name.
337    #[error("not a legal default branch name: {0}")]
338    BadBranchName(String),
339
340    /// A stored document was handed a value it will not keep.
341    ///
342    /// The empty policy document is the case this was added for: an empty
343    /// string reads back as *"a policy exists and configures nothing"*, which
344    /// is indistinguishable from a write that lost its content, so it is
345    /// refused and the caller is told which call removes a policy instead. It
346    /// is deliberately **not** `InvalidPath` — nothing about a path is wrong —
347    /// and deliberately not a `CorruptDocument`, which is about reading.
348    #[error("refused a {what}: {reason}")]
349    InvalidInput {
350        /// What was being stored.
351        what: &'static str,
352        /// Why it was refused, and what to do instead.
353        reason: String,
354    },
355
356    /// **Not enough room** (owner, 2026-10-04, "Add users"): the account's
357    /// room that is neither stored nor promised is less than what was asked.
358    /// Names how much is missing, because that is what the account admin has
359    /// to act on.
360    #[error(
361        "{account} has {} of room unused, and {} is asked: {} short. Add room to the account, or \
362         ask for fewer users or less room per user",
363        human_bytes(*unused),
364        human_bytes(*need),
365        human_bytes(*short)
366    )]
367    RoomShort {
368        /// The account.
369        account: String,
370        /// What the enrolment asked of it.
371        need: u64,
372        /// What it holds that is neither stored nor promised.
373        unused: u64,
374        /// What a sale would have to add for the ask to fit: more than
375        /// `need - unused` when the account already stores or promises more
376        /// than its cap.
377        short: u64,
378    },
379
380    /// **A single sign-on the identity table will not commit** (company SSO,
381    /// 2026-10-05): a subject bound elsewhere, a deactivated directory user,
382    /// an account with no identity provider, no free id for a new employee.
383    /// The sentence says which; it never carries a token or a secret.
384    #[error("single sign-on refused: {0}")]
385    SsoRefused(String),
386
387    /// **No open seat**: a seat claim on an account whose seats are all
388    /// filled, or that has none.
389    #[error("{0} has no open seat: every seat it holds is filled. Its admin adds users first")]
390    NoOpenSeat(String),
391
392    /// The storage layer failed.
393    #[error(transparent)]
394    Store(#[from] gunnar_store::Error),
395
396    /// **The control store failed** — the redb database the catalog and the
397    /// identity table live in (the certificate-identity plan, [`crate::control`]).
398    ///
399    /// Its own variant rather than an [`Error::Other`], because it is not the
400    /// same fault as [`Error::Store`]: that one is a *repository's* backend,
401    /// and an operator whose control plane is unreadable has a different
402    /// problem from one whose repository is. Never produced for "the document
403    /// has never been written", which is `Ok(None)`.
404    #[error("the control store failed: {0}")]
405    ControlStore(String),
406
407    /// **The control store is OFF THE STRIPE** — suspended by the runtime
408    /// data-set growth (`ControlDb::suspend`): `/data` is unmounted, every
409    /// read is answered from the RAM copy taken before it went, and every
410    /// WRITE is refused with this until the set is mounted and the store
411    /// resumed. `DATA-SET-GROWTH-FLOW.md` G4/T2/D7: refuse by name, never
412    /// queue; the caller retries once the growth is done. Maps to
413    /// `UNAVAILABLE` on the control plane, never to `INTERNAL`.
414    #[error(
415        "the control store is off the stripe while the data set grows ({why}); writes are refused \
416         until /data is mounted again — retry in a few minutes"
417    )]
418    OffStripe {
419        /// What the growth is doing right now.
420        why: String,
421    },
422
423    /// **This box's transaction log FOLLOWS another's** — it is being restored (or it is a
424    /// twin): it originates no write until the restore is whole. Not a fault of the server and
425    /// not the caller's: try again (the control plane answers UNAVAILABLE).
426    #[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")]
427    LogFollows,
428
429    /// A control document could not be encoded or decoded as JSON.
430    #[error(transparent)]
431    Json(#[from] serde_json::Error),
432
433    /// Anything else.
434    #[error(transparent)]
435    Other(#[from] anyhow::Error),
436
437    /// **A clan verb refused** (owner, 2026-10-05: "ordinary accounts can
438    /// group with other in a clan, where group created repos are administered
439    /// by the group admin"). `refusal` says which kind of no it is — the clan
440    /// is not there, the caller is not its admin, the rule (room, the floor,
441    /// the last admin, an employee) — and `reason` is the whole sentence the
442    /// person reads.
443    #[error("{reason}")]
444    Clan {
445        /// The clan (its namespace).
446        clan: String,
447        /// Which kind of refusal.
448        refusal: crate::auth::ClanRefusal,
449        /// The sentence.
450        reason: String,
451    },
452}
453
454impl Error {
455    /// `true` if this is the sandbox refusing an input, rather than a fault.
456    ///
457    /// The SSH layer answers these with a channel failure and a log line, and
458    /// must not confuse them with a storage error.
459    pub fn is_path_rejection(&self) -> bool {
460        matches!(self, Error::InvalidPath { .. } | Error::PathEscape { .. })
461    }
462
463    /// The [`PathReject`] behind an [`Error::InvalidPath`], for tests that
464    /// assert *which* rule fired.
465    pub fn path_reject(&self) -> Option<PathReject> {
466        match self {
467            Error::InvalidPath { reason, .. } => Some(*reason),
468            _ => None,
469        }
470    }
471
472    /// `true` if this is an access refusal.
473    ///
474    /// **Deliberately false for [`Error::Gated`].** The SSH and gRPC layers
475    /// route on these two, and folding a lockout into "denied" is how a user
476    /// gets sent to look for a grant that would change nothing.
477    pub fn is_denied(&self) -> bool {
478        matches!(self, Error::Denied { .. })
479    }
480
481    /// `true` if the account is locked out (see [`crate::gate`]).
482    pub fn is_gated(&self) -> bool {
483        matches!(self, Error::Gated { .. })
484    }
485}
486
487impl From<ssh_key::Error> for Error {
488    fn from(err: ssh_key::Error) -> Self {
489        Error::Key(err.to_string())
490    }
491}
492
493
494/// Bytes as a person reads them: `10 GiB`, `1.5 GiB`, `512 MiB`, `300 bytes`.
495pub fn human_bytes(bytes: u64) -> String {
496    const KIB: u64 = 1 << 10;
497    const MIB: u64 = 1 << 20;
498    const GIB: u64 = 1 << 30;
499    const TIB: u64 = 1 << 40;
500    let (unit, name) = match bytes {
501        b if b >= TIB => (TIB, "TiB"),
502        b if b >= GIB => (GIB, "GiB"),
503        b if b >= MIB => (MIB, "MiB"),
504        b if b >= KIB => (KIB, "KiB"),
505        _ => return format!("{bytes} bytes"),
506    };
507    let tenths = (u128::from(bytes) * 10 + u128::from(unit) / 2) / u128::from(unit);
508    if tenths % 10 == 0 {
509        format!("{} {name}", tenths / 10)
510    } else {
511        format!("{}.{} {name}", tenths / 10, tenths % 10)
512    }
513}