#![allow(dead_code)]
use std::io::Read;
use std::time::{Duration, SystemTime};
use serde::Deserialize;
use thiserror::Error;
use ureq::{http, Agent, Body};
#[derive(Debug, Error)]
pub enum Error {
#[error("{}", format_rate_limit_message(reset_at))]
RateLimited { reset_at: Option<SystemTime> },
#[error("GitHub API returned 403 Forbidden — check your GITHUB_TOKEN permissions")]
AuthFailed,
#[error("no releases found for {org}/{repo}")]
NoReleaseFound { org: String, repo: String },
#[error("no .tar.gz asset found for {org}/{repo} tag {tag}")]
NoTarballAsset {
org: String,
repo: String,
tag: String,
},
#[error("HTTP error: {method} {url} → {}", status.map_or("connection failed".to_string(), |s| format!("{s}")))]
Http {
method: String,
url: String,
status: Option<u16>,
},
}
fn format_rate_limit_message(reset_at: &Option<SystemTime>) -> String {
match reset_at {
Some(t) => {
let now = SystemTime::now();
let remaining = t.duration_since(now).unwrap_or(Duration::ZERO);
let mins = remaining.as_secs() / 60;
format!(
"GitHub API rate limit exceeded; resets in {mins} minute(s). \
Set GITHUB_TOKEN to raise the limit to 5000/hour"
)
}
None => "GitHub API rate limit exceeded. \
Set GITHUB_TOKEN to raise the limit to 5000/hour"
.to_string(),
}
}
#[derive(Debug, Deserialize)]
struct Release {
tag_name: String,
draft: bool,
assets: Vec<Asset>,
}
#[derive(Debug, Deserialize)]
struct Asset {
name: String,
browser_download_url: String,
size: u64,
}
pub struct GithubClient {
agent: Agent,
base_url: String,
user_agent: String,
token: Option<String>,
}
impl GithubClient {
pub fn new(base_url: &str, user_agent: &str, token: Option<String>) -> Self {
use ureq::config::RedirectAuthHeaders;
use ureq::tls::{RootCerts, TlsConfig};
let agent = Agent::config_builder()
.tls_config(
TlsConfig::builder()
.root_certs(RootCerts::PlatformVerifier)
.build(),
)
.redirect_auth_headers(RedirectAuthHeaders::SameHost)
.http_status_as_error(false)
.build()
.new_agent();
Self {
agent,
base_url: base_url.trim_end_matches('/').to_string(),
user_agent: user_agent.to_string(),
token,
}
}
pub fn from_env(base_url: &str, user_agent: &str) -> Self {
let token = std::env::var("GITHUB_TOKEN")
.ok()
.or_else(|| std::env::var("GH_TOKEN").ok());
Self::new(base_url, user_agent, token)
}
pub fn latest_release_tag(&self, org: &str, repo: &str) -> Result<String, Error> {
let url = format!("{}/repos/{}/{}/releases", self.base_url, org, repo);
let mut response = self.get(&url)?;
let status = response.status().as_u16();
if status == 403 {
return Err(classify_forbidden(&response));
}
if status == 404 || status == 410 {
return Err(Error::NoReleaseFound {
org: org.to_string(),
repo: repo.to_string(),
});
}
if status == 429 {
return Err(Error::RateLimited {
reset_at: parse_reset_header(&response),
});
}
if !is_success(status) {
return Err(Error::Http {
method: "GET".to_string(),
url,
status: Some(status),
});
}
let releases: Vec<Release> = response.body_mut().read_json().map_err(|_| Error::Http {
method: "GET".to_string(),
url: url.clone(),
status: None,
})?;
releases
.into_iter()
.find(|r| !r.draft)
.map(|r| r.tag_name)
.ok_or_else(|| Error::NoReleaseFound {
org: org.to_string(),
repo: repo.to_string(),
})
}
pub fn release_asset_body(
&self,
org: &str,
repo: &str,
tag: &str,
) -> Result<(u64, Box<dyn Read + Send>), Error> {
let url = format!("{}/repos/{}/{}/releases", self.base_url, org, repo);
let mut response = self.get(&url)?;
let status = response.status().as_u16();
if status == 403 {
return Err(classify_forbidden(&response));
}
if status == 404 || status == 410 {
return Err(Error::NoReleaseFound {
org: org.to_string(),
repo: repo.to_string(),
});
}
if status == 429 {
return Err(Error::RateLimited {
reset_at: parse_reset_header(&response),
});
}
if !is_success(status) {
return Err(Error::Http {
method: "GET".to_string(),
url,
status: Some(status),
});
}
let releases: Vec<Release> = response.body_mut().read_json().map_err(|_| Error::Http {
method: "GET".to_string(),
url: url.clone(),
status: None,
})?;
let release = releases
.into_iter()
.find(|r| r.tag_name == tag)
.ok_or_else(|| Error::NoReleaseFound {
org: org.to_string(),
repo: repo.to_string(),
})?;
let asset = release
.assets
.into_iter()
.find(|a| a.name.ends_with(".tar.gz"))
.ok_or_else(|| Error::NoTarballAsset {
org: org.to_string(),
repo: repo.to_string(),
tag: tag.to_string(),
})?;
let content_length = asset.size;
let asset_response = self.get(&asset.browser_download_url)?;
let asset_status = asset_response.status().as_u16();
if !is_success(asset_status) {
return Err(Error::Http {
method: "GET".to_string(),
url: asset.browser_download_url,
status: Some(asset_status),
});
}
let reader = asset_response.into_body().into_reader();
Ok((content_length, Box::new(reader)))
}
fn get(&self, url: &str) -> Result<http::Response<Body>, Error> {
let mut req = self
.agent
.get(url)
.header("Accept", "application/vnd.github+json")
.header("User-Agent", &self.user_agent)
.header("X-GitHub-Api-Version", "2022-11-28");
if let Some(ref token) = self.token {
req = req.header("Authorization", &format!("Bearer {token}"));
}
req.call().map_err(|e| sanitize_ureq_error("GET", url, e))
}
}
fn classify_forbidden(response: &http::Response<Body>) -> Error {
let remaining = response
.headers()
.get("x-ratelimit-remaining")
.and_then(|v| v.to_str().ok());
match remaining {
Some("0") => Error::RateLimited {
reset_at: parse_reset_header(response),
},
_ => Error::AuthFailed,
}
}
fn parse_reset_header(response: &http::Response<Body>) -> Option<SystemTime> {
response
.headers()
.get("x-ratelimit-reset")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<u64>().ok())
.map(|secs| SystemTime::UNIX_EPOCH + Duration::from_secs(secs))
}
fn sanitize_ureq_error(method: &str, url: &str, _err: ureq::Error) -> Error {
Error::Http {
method: method.to_string(),
url: url.to_string(),
status: None,
}
}
fn is_success(status: u16) -> bool {
(200..300).contains(&status)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn token_not_leaked_in_http_error_display() {
let sentinel = "sentinel_value_please_do_not_leak";
let err = Error::Http {
method: "GET".to_string(),
url: "https://api.github.com/repos/org/repo/releases".to_string(),
status: None,
};
let display = format!("{err}");
assert!(
!display.contains(sentinel),
"Error Display must never contain the token value"
);
}
#[test]
fn rate_limited_error_mentions_github_token() {
let err = Error::RateLimited { reset_at: None };
let display = format!("{err}");
assert!(
display.contains("GITHUB_TOKEN"),
"Rate limit error should suggest GITHUB_TOKEN"
);
}
#[test]
fn rate_limited_with_reset_time_formats_minutes() {
let reset_at = SystemTime::now() + Duration::from_secs(300);
let err = Error::RateLimited {
reset_at: Some(reset_at),
};
let display = format!("{err}");
assert!(
display.contains("minute"),
"Should mention minutes: {display}"
);
assert!(
display.contains("GITHUB_TOKEN"),
"Should suggest GITHUB_TOKEN"
);
}
#[test]
fn auth_failed_error_mentions_permissions() {
let err = Error::AuthFailed;
let display = format!("{err}");
assert!(display.contains("403"), "Should mention 403 status");
}
#[test]
fn no_release_found_names_org_and_repo() {
let err = Error::NoReleaseFound {
org: "myorg".to_string(),
repo: "myrepo".to_string(),
};
let display = format!("{err}");
assert!(display.contains("myorg/myrepo"));
}
#[test]
fn no_tarball_asset_names_tag() {
let err = Error::NoTarballAsset {
org: "o".to_string(),
repo: "r".to_string(),
tag: "v1.0.0".to_string(),
};
let display = format!("{err}");
assert!(display.contains("v1.0.0"));
}
fn fake_releases_json(server_url: &str) -> String {
serde_json::json!([
{
"tag_name": "v0.2.0",
"draft": false,
"assets": [{
"name": "omne-v0.2.0-x86_64-unknown-linux-gnu.tar.gz",
"browser_download_url": format!("{server_url}/download/v0.2.0/omne.tar.gz"),
"size": 1024
}]
},
{
"tag_name": "v0.1.0",
"draft": false,
"assets": [{
"name": "omne-v0.1.0-x86_64-unknown-linux-gnu.tar.gz",
"browser_download_url": format!("{server_url}/download/v0.1.0/omne.tar.gz"),
"size": 512
}]
}
])
.to_string()
}
#[test]
fn latest_release_tag_returns_first_non_draft() {
let mut server = mockito::Server::new();
let body = fake_releases_json(&server.url());
let _m = server
.mock("GET", "/repos/omne-org/omne/releases")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(&body)
.create();
let client = GithubClient::new(&server.url(), "test/1.0", None);
let tag = client.latest_release_tag("omne-org", "omne").unwrap();
assert_eq!(tag, "v0.2.0");
}
#[test]
fn latest_release_tag_skips_drafts() {
let mut server = mockito::Server::new();
let body = serde_json::json!([
{ "tag_name": "v0.3.0-draft", "draft": true, "assets": [] },
{ "tag_name": "v0.2.0", "draft": false, "assets": [] }
])
.to_string();
let _m = server
.mock("GET", "/repos/org/repo/releases")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(&body)
.create();
let client = GithubClient::new(&server.url(), "test/1.0", None);
let tag = client.latest_release_tag("org", "repo").unwrap();
assert_eq!(tag, "v0.2.0");
}
#[test]
fn latest_release_tag_empty_list_returns_no_release_found() {
let mut server = mockito::Server::new();
let _m = server
.mock("GET", "/repos/org/repo/releases")
.with_status(200)
.with_header("content-type", "application/json")
.with_body("[]")
.create();
let client = GithubClient::new(&server.url(), "test/1.0", None);
let err = client.latest_release_tag("org", "repo").unwrap_err();
assert!(
matches!(err, Error::NoReleaseFound { ref org, ref repo } if org == "org" && repo == "repo"),
"Expected NoReleaseFound, got: {err:?}"
);
}
#[test]
fn latest_release_tag_404_returns_no_release_found() {
let mut server = mockito::Server::new();
let _m = server
.mock("GET", "/repos/org/missing/releases")
.with_status(404)
.with_body("{\"message\": \"Not Found\"}")
.create();
let client = GithubClient::new(&server.url(), "test/1.0", None);
let err = client.latest_release_tag("org", "missing").unwrap_err();
assert!(matches!(err, Error::NoReleaseFound { .. }));
}
#[test]
fn rate_limit_403_with_headers_returns_rate_limited() {
let mut server = mockito::Server::new();
let reset_epoch = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_secs()
+ 600;
let _m = server
.mock("GET", "/repos/org/repo/releases")
.with_status(403)
.with_header("x-ratelimit-remaining", "0")
.with_header("x-ratelimit-reset", &reset_epoch.to_string())
.with_body("{\"message\": \"API rate limit exceeded\"}")
.create();
let client = GithubClient::new(&server.url(), "test/1.0", None);
let err = client.latest_release_tag("org", "repo").unwrap_err();
match err {
Error::RateLimited { reset_at } => {
assert!(reset_at.is_some(), "Should parse reset_at from header");
let display = format!("{err}");
assert!(display.contains("GITHUB_TOKEN"));
assert!(display.contains("minute"));
}
other => panic!("Expected RateLimited, got: {other:?}"),
}
}
#[test]
fn auth_failure_403_without_rate_limit_headers() {
let mut server = mockito::Server::new();
let _m = server
.mock("GET", "/repos/org/repo/releases")
.with_status(403)
.with_body("{\"message\": \"Bad credentials\"}")
.create();
let client = GithubClient::new(&server.url(), "test/1.0", Some("bad-token".into()));
let err = client.latest_release_tag("org", "repo").unwrap_err();
assert!(
matches!(err, Error::AuthFailed),
"Expected AuthFailed, got: {err:?}"
);
}
#[test]
fn rate_limit_429_returns_rate_limited() {
let mut server = mockito::Server::new();
let _m = server
.mock("GET", "/repos/org/repo/releases")
.with_status(429)
.with_header("retry-after", "60")
.with_body("{\"message\": \"Too Many Requests\"}")
.create();
let client = GithubClient::new(&server.url(), "test/1.0", None);
let err = client.latest_release_tag("org", "repo").unwrap_err();
assert!(
matches!(err, Error::RateLimited { .. }),
"Expected RateLimited, got: {err:?}"
);
}
#[test]
fn client_with_token_sends_authorization_header() {
let mut server = mockito::Server::new();
let body = fake_releases_json(&server.url());
let _m = server
.mock("GET", "/repos/org/repo/releases")
.match_header("Authorization", "Bearer test-token-123")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(&body)
.expect(1)
.create();
let client = GithubClient::new(&server.url(), "test/1.0", Some("test-token-123".into()));
let tag = client.latest_release_tag("org", "repo").unwrap();
assert_eq!(tag, "v0.2.0");
}
#[test]
fn client_without_token_sends_no_authorization_header() {
let mut server = mockito::Server::new();
let body = fake_releases_json(&server.url());
let _m = server
.mock("GET", "/repos/org/repo/releases")
.match_header("Authorization", mockito::Matcher::Missing)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(&body)
.expect(1)
.create();
let client = GithubClient::new(&server.url(), "test/1.0", None);
let tag = client.latest_release_tag("org", "repo").unwrap();
assert_eq!(tag, "v0.2.0");
}
#[test]
fn token_not_leaked_on_network_error() {
let client = GithubClient::new(
"http://127.0.0.1:1",
"test/1.0",
Some("sentinel_value_please_do_not_leak".into()),
);
let err = client.latest_release_tag("org", "repo").unwrap_err();
let display = format!("{err}");
assert!(
!display.contains("sentinel_value_please_do_not_leak"),
"Error must not leak token: {display}"
);
}
#[test]
fn release_asset_body_returns_reader_and_size() {
let mut server = mockito::Server::new();
let tarball_bytes = b"fake-tarball-content";
let body = serde_json::json!([{
"tag_name": "v1.0.0",
"draft": false,
"assets": [{
"name": "omne-v1.0.0.tar.gz",
"browser_download_url": format!("{}/download/v1.0.0/omne.tar.gz", server.url()),
"size": tarball_bytes.len()
}]
}])
.to_string();
let _m_releases = server
.mock("GET", "/repos/org/repo/releases")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(&body)
.create();
let _m_download = server
.mock("GET", "/download/v1.0.0/omne.tar.gz")
.with_status(200)
.with_body(tarball_bytes)
.create();
let client = GithubClient::new(&server.url(), "test/1.0", None);
let (size, mut reader) = client.release_asset_body("org", "repo", "v1.0.0").unwrap();
assert_eq!(size, tarball_bytes.len() as u64);
let mut buf = Vec::new();
reader.read_to_end(&mut buf).unwrap();
assert_eq!(buf, tarball_bytes);
}
#[test]
fn release_asset_body_no_tarball_asset() {
let mut server = mockito::Server::new();
let body = serde_json::json!([{
"tag_name": "v1.0.0",
"draft": false,
"assets": [{
"name": "omne-v1.0.0.zip",
"browser_download_url": format!("{}/download/omne.zip", server.url()),
"size": 100
}]
}])
.to_string();
let _m = server
.mock("GET", "/repos/org/repo/releases")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(&body)
.create();
let client = GithubClient::new(&server.url(), "test/1.0", None);
let result = client.release_asset_body("org", "repo", "v1.0.0");
let err = result.err().expect("expected error");
assert!(
matches!(err, Error::NoTarballAsset { ref tag, .. } if tag == "v1.0.0"),
"Expected NoTarballAsset, got: {err:?}"
);
}
#[test]
fn release_asset_body_tag_not_found() {
let mut server = mockito::Server::new();
let body = serde_json::json!([{
"tag_name": "v2.0.0",
"draft": false,
"assets": []
}])
.to_string();
let _m = server
.mock("GET", "/repos/org/repo/releases")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(&body)
.create();
let client = GithubClient::new(&server.url(), "test/1.0", None);
let result = client.release_asset_body("org", "repo", "v1.0.0");
let err = result.err().expect("expected error");
assert!(
matches!(err, Error::NoReleaseFound { .. }),
"Expected NoReleaseFound, got: {err:?}"
);
}
#[test]
fn release_asset_body_404_returns_no_release_found() {
let mut server = mockito::Server::new();
let _m = server
.mock("GET", "/repos/org/repo/releases")
.with_status(404)
.create();
let client = GithubClient::new(&server.url(), "test/1.0", None);
let result = client.release_asset_body("org", "repo", "v1.0.0");
let err = result.err().expect("expected error");
assert!(
matches!(err, Error::NoReleaseFound { .. }),
"Expected NoReleaseFound on 404, got: {err:?}"
);
}
#[test]
fn release_asset_body_429_returns_rate_limited() {
let mut server = mockito::Server::new();
let _m = server
.mock("GET", "/repos/org/repo/releases")
.with_status(429)
.with_header("retry-after", "60")
.create();
let client = GithubClient::new(&server.url(), "test/1.0", None);
let result = client.release_asset_body("org", "repo", "v1.0.0");
let err = result.err().expect("expected error");
assert!(
matches!(err, Error::RateLimited { .. }),
"Expected RateLimited on 429, got: {err:?}"
);
}
#[test]
fn client_sends_required_github_api_headers() {
let mut server = mockito::Server::new();
let body = fake_releases_json(&server.url());
let _m = server
.mock("GET", "/repos/org/repo/releases")
.match_header("Accept", "application/vnd.github+json")
.match_header("X-GitHub-Api-Version", "2022-11-28")
.match_header("User-Agent", "omne-cli/test")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(&body)
.expect(1)
.create();
let client = GithubClient::new(&server.url(), "omne-cli/test", None);
client.latest_release_tag("org", "repo").unwrap();
}
#[test]
fn from_env_prefers_github_token_over_gh_token() {
let mut server = mockito::Server::new();
let body = fake_releases_json(&server.url());
let _m = server
.mock("GET", "/repos/org/repo/releases")
.match_header("Authorization", "Bearer X")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(&body)
.expect(1)
.create();
let client = GithubClient::new(&server.url(), "test/1.0", Some("X".into()));
client.latest_release_tag("org", "repo").unwrap();
}
#[test]
#[ignore = "hits real api.github.com — run manually with `cargo test -- --ignored`"]
fn live_smoke_test_latest_release() {
let client = GithubClient::from_env("https://api.github.com", "omne-cli/test");
let tag = client.latest_release_tag("omne-org", "omne").unwrap();
assert!(
tag.starts_with('v'),
"Expected tag starting with 'v', got: {tag}"
);
}
}