Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

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)
    }
}