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}