Skip to main content

rtb_forge/
github.rs

1//! GitHub backend — works against GitHub Cloud (`api.github.com`) and
2//! GitHub Enterprise (`<enterprise-host>/api/v3`).
3//!
4//! # Dependency choice
5//!
6//! The v0.1 spec suggested `octocrab` as the HTTP client. This
7//! implementation goes direct on `reqwest` instead, for three
8//! reasons:
9//!
10//! 1. Lighter dependency graph — `octocrab` pulls in ~30 transitive
11//!    crates including WebSocket plumbing we don't use.
12//! 2. `octocrab` does not cleanly expose a streaming asset
13//!    download (the spec requires `AsyncRead`, not a `Bytes` blob).
14//! 3. We need precise control over rate-limit header parsing to
15//!    populate `ProviderError::RateLimited::retry_after`.
16//!
17//! All four trait methods are small — ~40 LOC between them. No real
18//! ergonomics are lost by going direct.
19//!
20//! # Authentication
21//!
22//! Personal Access Token via the `Authorization: Bearer <token>`
23//! header. The [`SecretString`] is kept wrapped until the per-request
24//! header is built; we never log the exposed value.
25//!
26//! # Lint exception
27//!
28//! This module allows `unsafe_code` at module level because
29//! `linkme::distributed_slice`'s expansion emits a `#[link_section]`
30//! attribute that Rust 1.95+ flags under the `unsafe_code` lint. No
31//! hand-rolled `unsafe` blocks exist in this module.
32//!
33//! # Rate limits
34//!
35//! GitHub returns `403` + `X-RateLimit-Remaining: 0` when the caller
36//! exhausts their budget. We surface this as
37//! [`ProviderError::RateLimited`] with `retry_after` populated from
38//! either the `Retry-After` header or the `X-RateLimit-Reset` epoch
39//! seconds value, whichever is present.
40
41#![allow(unsafe_code)]
42
43use std::sync::Arc;
44
45use async_trait::async_trait;
46use linkme::distributed_slice;
47use secrecy::{ExposeSecret, SecretString};
48use tokio::io::AsyncRead;
49
50use crate::config::ReleaseSourceConfig;
51use crate::http;
52use crate::release::{
53    ProviderError, ProviderFactory, ProviderRegistration, RegisteredProvider, Release,
54    ReleaseAsset, ReleaseProvider, RELEASE_PROVIDERS,
55};
56
57/// GitHub-specific release provider. Register via the link-time
58/// registration in this module; callers construct it through
59/// [`factory`].
60pub struct GithubProvider {
61    client: reqwest::Client,
62    /// URL scheme — always `"https"` in production; `"http"` only
63    /// when `allow_insecure_base_url` is enabled (test-only escape
64    /// hatch).
65    scheme: &'static str,
66    host: String,
67    owner: String,
68    repo: String,
69    /// Held so per-request headers can be built without re-borrowing
70    /// the token past `Authorization`'s lifetime.
71    token: Option<SecretString>,
72}
73
74// ---------------------------------------------------------------------
75// Host normalisation
76// ---------------------------------------------------------------------
77
78/// Normalise a user-supplied GitHub host into an API URL with no
79/// trailing slash and an `/api/...` path where appropriate.
80///
81/// Rules:
82///
83/// - Leading `https://` / `http://` is stripped (the factory enforces
84///   HTTPS separately — see [`factory`]).
85/// - Trailing slashes are trimmed.
86/// - `api.github.com` passes through (that is the Cloud API root).
87/// - A bare Enterprise hostname (no `/api/...`) is promoted to
88///   `<host>/api/v3`.
89/// - `api.<host>` (a common Enterprise-before-v3 convention) is
90///   promoted to `<host>/api/v3`.
91fn normalise_host(raw: &str) -> String {
92    let stripped =
93        raw.trim_end_matches('/').trim_start_matches("https://").trim_start_matches("http://");
94    if stripped == "api.github.com" || stripped.ends_with("/api/v3") || stripped.contains("/api/") {
95        stripped.to_string()
96    } else if let Some(rest) = stripped.strip_prefix("api.") {
97        format!("{rest}/api/v3")
98    } else {
99        format!("{stripped}/api/v3")
100    }
101}
102
103// ---------------------------------------------------------------------
104// Factory + registration
105// ---------------------------------------------------------------------
106
107/// Build a [`GithubProvider`] from a parsed config and an optional
108/// PAT. Enforces HTTPS at construction; any URL with an explicit
109/// `http://` scheme is rejected via [`ProviderError::InvalidConfig`].
110///
111/// # Errors
112///
113/// Returns [`ProviderError::InvalidConfig`] if the host is empty or
114/// the reqwest client fails to build (TLS stack misconfiguration, etc.).
115pub fn factory(
116    cfg: &ReleaseSourceConfig,
117    token: Option<SecretString>,
118) -> Result<Arc<dyn ReleaseProvider>, ProviderError> {
119    let ReleaseSourceConfig::Github(params) = cfg else {
120        return Err(ProviderError::InvalidConfig(format!(
121            "github factory called with non-github config: source_type={}",
122            cfg.source_type()
123        )));
124    };
125
126    if params.host.trim().is_empty() {
127        return Err(ProviderError::InvalidConfig("github host must not be empty".to_string()));
128    }
129    if params.host.starts_with("http://") {
130        return Err(ProviderError::InvalidConfig(format!(
131            "github host must be https; got {}",
132            params.host
133        )));
134    }
135    if params.owner.trim().is_empty() || params.repo.trim().is_empty() {
136        return Err(ProviderError::InvalidConfig(
137            "github owner and repo must not be empty".to_string(),
138        ));
139    }
140
141    // Under `allow_insecure_base_url`, skip `normalise_host`. The test
142    // escape hatch exists to point the provider at a local mock server
143    // verbatim; `normalise_host` would otherwise promote the bare
144    // `127.0.0.1:PORT` to `.../api/v3`, breaking path-based mock
145    // matchers. Production callers go through normalisation.
146    let host = if params.allow_insecure_base_url {
147        params.host.trim_end_matches('/').to_string()
148    } else {
149        normalise_host(&params.host)
150    };
151    let client = http::build_client(params.timeout_seconds, params.allow_insecure_base_url)?;
152    let scheme = http::scheme_for(params.allow_insecure_base_url);
153
154    Ok(Arc::new(GithubProvider {
155        client,
156        scheme,
157        host,
158        owner: params.owner.clone(),
159        repo: params.repo.clone(),
160        token,
161    }))
162}
163
164/// Link-time registration entry. See [`crate::release::RELEASE_PROVIDERS`]
165/// for why `ProviderRegistration` is the slice element type.
166#[distributed_slice(RELEASE_PROVIDERS)]
167fn __register_github() -> Box<dyn ProviderRegistration> {
168    Box::new(RegisteredProvider { source_type: "github", factory: factory as ProviderFactory })
169}
170
171// ---------------------------------------------------------------------
172// Trait impl
173// ---------------------------------------------------------------------
174
175#[async_trait]
176impl ReleaseProvider for GithubProvider {
177    async fn latest_release(&self) -> Result<Release, ProviderError> {
178        let url = format!(
179            "{scheme}://{host}/repos/{owner}/{repo}/releases/latest",
180            scheme = self.scheme,
181            host = self.host,
182            owner = self.owner,
183            repo = self.repo
184        );
185        let resp = self.send(&url).await?;
186        let dto: ApiRelease = http::parse_json(resp).await?;
187        Ok(dto.into_release())
188    }
189
190    async fn release_by_tag(&self, tag: &str) -> Result<Release, ProviderError> {
191        let url = format!(
192            "{scheme}://{host}/repos/{owner}/{repo}/releases/tags/{tag}",
193            scheme = self.scheme,
194            host = self.host,
195            owner = self.owner,
196            repo = self.repo,
197            tag = http::urlencode(tag),
198        );
199        let resp = self.send(&url).await?;
200        let dto: ApiRelease = http::parse_json(resp).await?;
201        Ok(dto.into_release())
202    }
203
204    async fn list_releases(&self, limit: usize) -> Result<Vec<Release>, ProviderError> {
205        // GitHub caps `per_page` at 100; larger `limit`s would require
206        // pagination. v0.1 scopes to a single page — the caller rarely
207        // wants more than a handful of recent releases.
208        let per_page = limit.clamp(1, 100);
209        let url = format!(
210            "{scheme}://{host}/repos/{owner}/{repo}/releases?per_page={per_page}",
211            scheme = self.scheme,
212            host = self.host,
213            owner = self.owner,
214            repo = self.repo
215        );
216        let resp = self.send(&url).await?;
217        let dtos: Vec<ApiRelease> = http::parse_json(resp).await?;
218        Ok(dtos.into_iter().take(limit).map(ApiRelease::into_release).collect())
219    }
220
221    async fn download_asset(
222        &self,
223        asset: &ReleaseAsset,
224    ) -> Result<(Box<dyn AsyncRead + Send + Unpin>, u64), ProviderError> {
225        // Asset downloads require `Accept: application/octet-stream` so
226        // GitHub serves bytes rather than the asset's JSON metadata.
227        let mut req = self
228            .client
229            .get(&asset.download_url)
230            .header("Accept", "application/octet-stream")
231            .header("X-GitHub-Api-Version", "2022-11-28");
232        if let Some(tok) = &self.token {
233            req = req.bearer_auth(tok.expose_secret());
234        }
235        let resp = req.send().await.map_err(|e| ProviderError::Transport(e.to_string()))?;
236        check_status(&resp, &self.host)?;
237        Ok(http::stream_body(resp))
238    }
239}
240
241// ---------------------------------------------------------------------
242// HTTP helpers
243// ---------------------------------------------------------------------
244
245impl GithubProvider {
246    async fn send(&self, url: &str) -> Result<reqwest::Response, ProviderError> {
247        let mut req = self
248            .client
249            .get(url)
250            .header("Accept", "application/vnd.github+json")
251            .header("X-GitHub-Api-Version", "2022-11-28");
252        if let Some(tok) = &self.token {
253            req = req.bearer_auth(tok.expose_secret());
254        }
255        let resp = req.send().await.map_err(|e| ProviderError::Transport(e.to_string()))?;
256        check_status(&resp, &self.host)?;
257        Ok(resp)
258    }
259}
260
261/// GitHub-specific status check: forwards to [`http::map_status_to_error`]
262/// with the GitHub-only signal that `403` + `X-RateLimit-Remaining: 0`
263/// is a rate-limit, not an auth failure.
264fn check_status(resp: &reqwest::Response, host: &str) -> Result<(), ProviderError> {
265    let extra_rate_limit_signal = resp.status() == reqwest::StatusCode::FORBIDDEN
266        && http::header_str(resp.headers(), "x-ratelimit-remaining") == Some("0");
267    http::map_status_to_error(resp, host, extra_rate_limit_signal)
268}
269
270// ---------------------------------------------------------------------
271// DTOs
272// ---------------------------------------------------------------------
273
274#[derive(Debug, serde::Deserialize)]
275struct ApiRelease {
276    #[serde(default)]
277    id: u64,
278    #[serde(default)]
279    name: Option<String>,
280    tag_name: String,
281    #[serde(default)]
282    body: Option<String>,
283    #[serde(default)]
284    draft: bool,
285    #[serde(default)]
286    prerelease: bool,
287    created_at: String,
288    #[serde(default)]
289    published_at: Option<String>,
290    #[serde(default)]
291    assets: Vec<ApiAsset>,
292}
293
294#[derive(Debug, serde::Deserialize)]
295struct ApiAsset {
296    id: u64,
297    name: String,
298    #[serde(default)]
299    size: u64,
300    #[serde(default)]
301    content_type: Option<String>,
302    #[serde(rename = "browser_download_url")]
303    download_url: String,
304}
305
306impl ApiRelease {
307    fn into_release(self) -> Release {
308        let created_at =
309            parse_iso8601(&self.created_at).unwrap_or(time::OffsetDateTime::UNIX_EPOCH);
310        let published_at = self.published_at.as_deref().and_then(parse_iso8601);
311        let name = self.name.unwrap_or_else(|| self.tag_name.clone());
312        let body = self.body.unwrap_or_default();
313        let tag = self.tag_name.clone();
314        let _ = self.id;
315        let mut release = Release::new(name, tag, created_at);
316        release.body = body;
317        release.draft = self.draft;
318        release.prerelease = self.prerelease;
319        release.published_at = published_at;
320        release.assets = self.assets.into_iter().map(ApiAsset::into_asset).collect();
321        release
322    }
323}
324
325impl ApiAsset {
326    fn into_asset(self) -> ReleaseAsset {
327        let mut a = ReleaseAsset::new(self.id.to_string(), self.name, self.download_url);
328        a.size = self.size;
329        a.content_type = self.content_type;
330        a
331    }
332}
333
334fn parse_iso8601(s: &str) -> Option<time::OffsetDateTime> {
335    time::OffsetDateTime::parse(s, &time::format_description::well_known::Rfc3339).ok()
336}
337
338// ---------------------------------------------------------------------
339// Unit tests (host normalisation only; backend roundtrips live in
340// tests/github_backend.rs against wiremock)
341// ---------------------------------------------------------------------
342
343#[cfg(test)]
344mod tests {
345    use super::normalise_host;
346
347    #[test]
348    fn normalises_api_github_com() {
349        assert_eq!(normalise_host("api.github.com"), "api.github.com");
350        assert_eq!(normalise_host("https://api.github.com"), "api.github.com");
351        assert_eq!(normalise_host("https://api.github.com/"), "api.github.com");
352    }
353
354    #[test]
355    fn promotes_bare_enterprise_host() {
356        assert_eq!(normalise_host("github.example.com"), "github.example.com/api/v3");
357        assert_eq!(normalise_host("https://github.example.com/"), "github.example.com/api/v3");
358    }
359
360    #[test]
361    fn promotes_api_prefixed_enterprise_host() {
362        // Some Enterprise installs sit on `api.<host>` pre-v3.
363        assert_eq!(normalise_host("api.github.example.com"), "github.example.com/api/v3");
364    }
365
366    #[test]
367    fn preserves_explicit_api_path() {
368        assert_eq!(normalise_host("github.example.com/api/v3"), "github.example.com/api/v3");
369    }
370}