crates/gunnar-core/src/error.rs
//! 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)
}
}