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}