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",
}
}