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

nordisk/gunnar

crates/gunnar-forge/src/error.rs

//! **Errors that cannot carry a token, by construction rather than by care.**
//!
//! # The threat, stated exactly
//!
//! A personal access token is a bearer credential with, on GitHub's classic
//! tokens, no expiry at all. It reaches this crate as one `&str` argument. The
//! ways a crate like this normally leaks one are all *accidents of
//! convenience*:
//!
//! 1. an error type that keeps the failing **request** so the message can say
//!    what was attempted — and a request carries its headers;
//! 2. a `#[derive(Debug)]` on a client struct that **stored** the token in a
//!    field, printed by `{:?}`, by `unwrap()`'s panic message, and by every
//!    `tracing` span that takes the struct;
//! 3. a transport error whose `Display` is the underlying library's, which
//!    includes the URL — harmless while the token is a header, fatal the day
//!    someone "simplifies" it into a query parameter.
//!
//! # What this module does instead
//!
//! **The token is never stored.** [`crate::Forge`] holds a kind, a base URL
//! and a `ureq::Agent`. The token is a parameter of `list_repos` and dies with
//! the call. There is no field to print, so #2 is not defended against — it is
//! impossible.
//!
//! **No variant holds a request, a response body, a header map or a URL.** The
//! most any of them carries is the forge name, an HTTP status, and — for
//! [`ForgeError::RateLimited`] — a `Retry-After` duration, which is a number.
//! So #1 and #3 have nothing to reach. `Transport` keeps ureq's *kind*
//! (`Dns`, `ConnectionFailed`, …) and drops its `Display`, which is the one
//! place a URL would have got in.
//!
//! **`Malformed` is the one variant with free text**, and it is written by
//! this crate's own parsers about JSON that came back from the forge. It never
//! sees the request side. Its text is a field name, never a value, because a
//! *value* could be an OAuth device code or a repository someone's employer
//! would rather not see in a log.
//!
//! `tests/token_never_leaks.rs` asserts all of this against a real token
//! string driven through every failure path, including the panic message of a
//! deliberate `{:?}` on the client. Seen RED first against a draft whose
//! `Transport` variant rendered ureq's own `Display`, which put the whole
//! request — path, query string and all — into the message; the measured
//! output is quoted at the top of that file.

use std::time::Duration;

use crate::repo::ForgeKind;

/// Everything that can go wrong listing repositories.
///
/// See the module docs for why no variant carries a request.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum ForgeError {
    /// 401. The token is missing, wrong, revoked, or expired.
    ///
    /// It names the forge because a caller holding three tokens has to know
    /// *which* one to re-issue, and that is the entire useful content of a
    /// 401 — the body GitHub returns is `{"message":"Bad credentials", ...}`,
    /// which adds nothing and is one field rename away from echoing input.
    #[error("{forge}: the token was rejected (HTTP 401); re-issue it and try again")]
    Unauthorized {
        /// Which of the caller's forges rejected the credential.
        forge: ForgeKind,
    },

    /// 403 or 429 with a rate limit.
    ///
    /// GitHub answers a secondary rate limit with **403** and a `Retry-After`
    /// in seconds; the primary limit is a 403 with `x-ratelimit-remaining: 0`
    /// and no `Retry-After` at all. GitLab and Forgejo use 429. All three land
    /// here, because a caller's response to every one of them is the same: wait
    /// and ask again, or tell the human how long.
    ///
    /// `retry_after` is `None` when the forge did not say — which is not the
    /// same as "retry immediately", and a caller that treats it as zero will
    /// be rate-limited again inside a second.
    #[error("{forge}: rate limited (HTTP {status}){}", match retry_after {
        Some(d) => format!("; retry after {}s", d.as_secs()),
        None => String::new(),
    })]
    RateLimited {
        /// Which forge is throttling.
        forge: ForgeKind,
        /// 403 (GitHub's secondary limit) or 429 (everyone else).
        status: u16,
        /// What the forge asked us to wait, when it said.
        retry_after: Option<Duration>,
    },

    /// Any other non-2xx. The status is the whole message on purpose.
    #[error("{forge}: the forge answered HTTP {status}")]
    Http {
        /// Which forge answered.
        forge: ForgeKind,
        /// The status it answered with.
        status: u16,
    },

    /// The request never got an answer: DNS, TCP, TLS, timeout.
    ///
    /// `kind` is ureq's `ErrorKind` rendered as a short static word. ureq's own
    /// `Display` for a transport error embeds the URL, and this crate refuses
    /// to put a URL in an error — see the module docs, reason #3.
    #[error("{forge}: could not reach the forge ({kind})")]
    Transport {
        /// Which forge could not be reached.
        forge: ForgeKind,
        /// A short static word for the failure. Never a URL — see the docs.
        kind: &'static str,
    },

    /// The forge answered 2xx with something this crate could not read.
    ///
    /// `what` names a JSON *field or shape*, never a value.
    #[error("{forge}: could not read the repository list ({what})")]
    Malformed {
        /// Which forge sent the unreadable body.
        forge: ForgeKind,
        /// A JSON field or shape. NEVER a value.
        what: String,
    },

    /// Pagination did not terminate.
    ///
    /// A forge that keeps saying "there is a next page" — a proxy that ignores
    /// the `page` parameter is the realistic cause — would otherwise be an
    /// unbounded loop holding a token in memory. See `ListOptions::max_pages`.
    #[error("{forge}: stopped after {pages} pages; the forge never said it was done")]
    TooManyPages {
        /// Which forge would not stop paginating.
        forge: ForgeKind,
        /// How many pages were fetched before giving up.
        pages: u32,
    },
}

impl ForgeError {
    /// The forge that failed. Every variant has one, which is what makes a
    /// multi-forge picker able to grey out one row and keep the other two.
    pub fn forge(&self) -> ForgeKind {
        match self {
            ForgeError::Unauthorized { forge }
            | ForgeError::RateLimited { forge, .. }
            | ForgeError::Http { forge, .. }
            | ForgeError::Transport { forge, .. }
            | ForgeError::Malformed { forge, .. }
            | ForgeError::TooManyPages { forge, .. } => *forge,
        }
    }

    /// The HTTP status, when there was a response at all.
    pub fn status(&self) -> Option<u16> {
        match self {
            ForgeError::Unauthorized { .. } => Some(401),
            ForgeError::RateLimited { status, .. } | ForgeError::Http { status, .. } => {
                Some(*status)
            }
            _ => None,
        }
    }

    /// Is waiting the right response? True for a rate limit and for a
    /// transport failure, false for a bad token — retrying a 401 forever is
    /// the loop that gets an account locked.
    pub fn is_retryable(&self) -> bool {
        matches!(
            self,
            ForgeError::RateLimited { .. } | ForgeError::Transport { .. }
        )
    }
}