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/engine.rs

//! **The one place a token is attached to a request, and the one place an HTTP
//! status becomes a `ForgeError`.**
//!
//! Everything security-relevant about this crate is in this file, which is the
//! point: `tests/token_never_leaks.rs` has one code path to cover rather than
//! three, and a fourth forge inherits the property instead of re-earning it.
//!
//! # Why the pagination loop lives here and not in the drivers
//!
//! The three forges disagree about how you learn there is a next page —
//! GitHub's `Link: <…>; rel="next"`, Forgejo's `X-Total-Count`, GitLab's
//! `X-Next-Page` — but they agree completely about what you do with the answer:
//! ask for the next page with the same credential and concatenate. Written per
//! driver, that shared half would be copied three times, and the copy that
//! forgets [`ListOptions::max_pages`] is an unbounded loop holding a live
//! credential in memory against a forge that is misbehaving.
//!
//! So the driver answers *"is there a next one, and which"* and the engine owns
//! the loop, the cap, and the monotonicity check that makes a driver's mistake
//! cost one wasted request instead of a spin.
//!
//! # Two ureq settings that are load-bearing, not defaults-by-accident
//!
//! * **`RedirectAuthHeaders::Never`** (ureq's own default, restated here so a
//!   future edit of this builder cannot silently drop it). A forge that answers
//!   `302` to an attacker-chosen host must not be handed the user's token by
//!   our HTTP client. Restating it costs one line and turns an upstream default
//!   into this crate's stated position.
//! * **timeouts on every phase**. Without them a hung TLS handshake parks a
//!   thread — which on the CLI is the user's terminal, and on the BFF is one of
//!   a bounded pool — forever. The picker's whole promise is that it comes back.

use std::time::Duration;

use crate::driver::{ForgeDriver, Headers, PageMeta};
use crate::error::ForgeError;
use crate::repo::Repo;

/// How the listing is paced. Defaults suit a picker: everything, biggest pages
/// the forges allow, and a cap that is far above any real account.
#[derive(Clone, Debug)]
pub struct ListOptions {
    /// Page size. All three forges cap this at 100 and silently clamp anything
    /// larger, so 100 is both the default and the maximum worth asking for.
    pub per_page: u32,
    /// Refuse to go past this many pages. At the default page size that is
    /// 100 000 repositories; the largest account anyone has is orders of
    /// magnitude below it, so hitting this means the forge (or a proxy in
    /// front of it) is not paginating, which is [`ForgeError::TooManyPages`].
    pub max_pages: u32,
    /// Whole-request timeout, per page.
    pub timeout: Duration,
}

impl Default for ListOptions {
    fn default() -> Self {
        Self {
            per_page: 100,
            max_pages: 1000,
            timeout: Duration::from_secs(30),
        }
    }
}

/// Build the agent every [`crate::Forge`] uses. See the module docs for the two
/// settings that are deliberate.
pub(crate) fn agent(timeout: Duration) -> ureq::Agent {
    ureq::Agent::config_builder()
        .redirect_auth_headers(ureq::config::RedirectAuthHeaders::Never)
        .timeout_connect(Some(Duration::from_secs(10)))
        .timeout_global(Some(timeout))
        // A `User-Agent` is not politeness: `api.github.com` answers **403** to
        // a request without one, which would surface as a rate limit and send a
        // caller off to wait for a window that was never the problem.
        .user_agent(concat!("gunnar-forge/", env!("CARGO_PKG_VERSION")))
        // ureq 3 (2026-10-02) restated to ureq 2's behaviour: a non-2xx is a
        // response (ureq 2 handed it over in `Error::Status`; `list_all` reads its
        // rate-limit headers and drops it), no proxy taken from the environment,
        // and at most 5 redirects.
        .http_status_as_error(false)
        .proxy(None)
        .max_redirects(5)
        .build()
        .new_agent()
}

/// Fetch every page and concatenate. The token is used here and nowhere else.
pub(crate) fn list_all(
    agent: &ureq::Agent,
    base: &str,
    driver: &dyn ForgeDriver,
    token: &str,
    opts: &ListOptions,
) -> Result<Vec<Repo>, ForgeError> {
    let kind = driver.kind();
    let auth = driver.auth();
    let base = base.trim_end_matches('/');

    let mut out: Vec<Repo> = Vec::new();
    let mut page: u32 = 1;
    let mut fetched: u32 = 0;

    loop {
        if fetched >= opts.max_pages {
            return Err(ForgeError::TooManyPages {
                forge: kind,
                pages: fetched,
            });
        }

        let url = format!("{base}{}", driver.page_path(page, opts.per_page));
        let mut req = agent.get(&url);
        for (k, v) in driver.extra_headers() {
            req = req.header(*k, *v);
        }
        // THE line. The token exists in this crate as this expression's
        // temporary and as the `&str` it was borrowed from, and nowhere else.
        req = req.header(auth.name, &format!("{}{}", auth.prefix, token));

        let resp = match req.call() {
            Ok(resp) => resp,
            Err(e) => {
                return Err(ForgeError::Transport {
                    forge: kind,
                    kind: transport_kind(&e),
                });
            }
        };
        let status = resp.status().as_u16();
        let headers = collect_headers(&resp);
        // A 4xx/5xx comes back as a response (the agent does not turn it into an
        // error), which is how the 403 rate-limit case gets at `Retry-After`. The
        // response is read for headers and then DROPPED — the body of an error is
        // never kept and never rendered, because a forge is free to echo the
        // request in it and some do.
        if status >= 400 {
            return Err(classify(kind, status, &headers));
        }
        let body = resp
            .into_body()
            .read_to_string()
            .map_err(|_| ForgeError::Malformed {
                forge: kind,
                what: "the response body was not valid UTF-8".to_string(),
            })?;

        if !(200..300).contains(&status) {
            return Err(classify(kind, status, &headers));
        }

        let repos = driver.parse_page(&body)?;
        let received = repos.len();
        out.extend(repos);
        fetched += 1;

        let meta = PageMeta {
            page,
            per_page: opts.per_page,
            received,
            seen_total: out.len(),
            headers: &headers,
        };
        match driver.next_page(&meta) {
            // Strictly greater, or we are done. A driver that misreads a header
            // and answers "page 1" forever costs one wasted request here rather
            // than a loop that never returns.
            Some(next) if next > page => page = next,
            _ => return Ok(out),
        }
    }
}

/// Status → error. The **only** classifier; see the module docs.
///
/// The 401/403 split is not cosmetic. A 401 means *this token is wrong* and
/// retrying it is how an account gets locked; a 403-with-a-rate-limit means
/// *this token is fine, you are early*. A caller that cannot tell them apart
/// either retries a dead token forever or gives up on a live one.
fn classify(forge: crate::repo::ForgeKind, status: u16, headers: &Headers) -> ForgeError {
    match status {
        401 => ForgeError::Unauthorized { forge },
        // 429 is always a rate limit. 403 is one only when the forge says so:
        // GitHub uses 403 for *both* a secondary rate limit and an ordinary
        // "your token lacks this scope", and reporting the second as
        // retryable would make a caller wait out a permission problem.
        //
        // The three signals, in the order they are trusted:
        //   `Retry-After`            — explicit, and the only one with a number
        //   `x-ratelimit-remaining: 0` — GitHub's primary limit, no Retry-After
        //   `RateLimit-Remaining: 0`   — the IETF draft spelling, GitLab's
        429 => ForgeError::RateLimited {
            forge,
            status,
            retry_after: retry_after(headers),
        },
        403 if is_rate_limit(headers) => ForgeError::RateLimited {
            forge,
            status,
            retry_after: retry_after(headers),
        },
        _ => ForgeError::Http { forge, status },
    }
}

fn is_rate_limit(headers: &Headers) -> bool {
    if headers.get("retry-after").is_some() {
        return true;
    }
    for name in ["x-ratelimit-remaining", "ratelimit-remaining"] {
        if let Some(v) = headers.get(name) {
            if v.trim() == "0" {
                return true;
            }
        }
    }
    false
}

/// `Retry-After` as a duration.
///
/// Only the delta-seconds form is read. The HTTP-date form is legal and no
/// forge in this file uses it; parsing RFC 7231 dates would cost a date crate
/// for a case that does not occur, and a wrong date parsed into a wrong
/// duration is worse than `None` — `None` means "the forge did not say", which
/// a caller already has to handle.
fn retry_after(headers: &Headers) -> Option<Duration> {
    headers
        .get("retry-after")
        .and_then(|v| v.trim().parse::<u64>().ok())
        .map(Duration::from_secs)
}

fn collect_headers(resp: &ureq::http::Response<ureq::Body>) -> Headers {
    let mut v = Vec::new();
    for (name, val) in resp.headers() {
        if let Ok(val) = val.to_str() {
            v.push((name.as_str().to_string(), val.to_string()));
        }
    }
    Headers(v)
}

/// A ureq transport error as a short static word.
///
/// A `match` and not `format!("{e:?}")`, so the set of words this crate can
/// print is closed and reviewable. ureq's `Display` for the error embeds the URL
/// (and a `Debug` may too), and this crate does not put URLs in errors.
///
/// ureq 3 (2026-10-02) has no separate `ErrorKind`, so the error itself is matched.
/// The words are ureq 2's, so callers see the same vocabulary: a timeout stays
/// "i/o" (ureq 2 surfaced it as an I/O error), TLS failures get "tls". ureq 3's
/// `Error` is `#[non_exhaustive]`, so a variant added later lands on "other"
/// instead of failing to compile — still one closed word, never a URL.
fn transport_kind(e: &ureq::Error) -> &'static str {
    use ureq::Error::*;
    match e {
        BadUri(_) | Http(_) => "invalid url",
        HostNotFound => "dns",
        RequireHttpsOnly(_) | TlsRequired => "https required",
        ConnectionFailed => "connection failed",
        TooManyRedirects => "too many redirects",
        RedirectFailed | LargeResponseHeader(..) => "malformed header",
        Protocol(_) => "malformed response",
        Io(_) | Timeout(_) | BodyStalled => "i/o",
        BodyExceedsLimit(_) => "body too large",
        InvalidProxyUrl => "invalid proxy url",
        ConnectProxyFailed(_) => "proxy connect",
        Tls(_) | Rustls(_) | Pem(_) => "tls",
        // The agent never turns a status into an error (see `agent`); listed so
        // the arm is explicit rather than folded into "other".
        StatusCode(_) => "http",
        _ => "other",
    }
}