1#![allow(dead_code)]
15
16use std::io::Read;
17use std::time::{Duration, SystemTime};
18
19use serde::Deserialize;
20use thiserror::Error;
21use ureq::{http, Agent, Body};
22
23#[derive(Debug, Error)]
29pub enum Error {
30 #[error("{}", format_rate_limit_message(reset_at))]
33 RateLimited { reset_at: Option<SystemTime> },
34
35 #[error("GitHub API returned 403 Forbidden — check your GITHUB_TOKEN permissions")]
37 AuthFailed,
38
39 #[error("no releases found for {org}/{repo}")]
41 NoReleaseFound { org: String, repo: String },
42
43 #[error("no .tar.gz asset found for {org}/{repo} tag {tag}")]
45 NoTarballAsset {
46 org: String,
47 repo: String,
48 tag: String,
49 },
50
51 #[error("HTTP error: {method} {url} → {}", status.map_or("connection failed".to_string(), |s| format!("{s}")))]
54 Http {
55 method: String,
56 url: String,
57 status: Option<u16>,
58 },
59}
60
61fn format_rate_limit_message(reset_at: &Option<SystemTime>) -> String {
62 match reset_at {
63 Some(t) => {
64 let now = SystemTime::now();
65 let remaining = t.duration_since(now).unwrap_or(Duration::ZERO);
66 let mins = remaining.as_secs() / 60;
67 format!(
68 "GitHub API rate limit exceeded; resets in {mins} minute(s). \
69 Set GITHUB_TOKEN to raise the limit to 5000/hour"
70 )
71 }
72 None => "GitHub API rate limit exceeded. \
73 Set GITHUB_TOKEN to raise the limit to 5000/hour"
74 .to_string(),
75 }
76}
77
78#[derive(Debug, Deserialize)]
80struct Release {
81 tag_name: String,
82 draft: bool,
83 assets: Vec<Asset>,
84}
85
86#[derive(Debug, Deserialize)]
88struct Asset {
89 name: String,
90 browser_download_url: String,
91 size: u64,
92}
93
94pub struct GithubClient {
100 agent: Agent,
101 base_url: String,
102 user_agent: String,
103 token: Option<String>,
104}
105
106impl GithubClient {
107 pub fn new(base_url: &str, user_agent: &str, token: Option<String>) -> Self {
112 use ureq::config::RedirectAuthHeaders;
113 use ureq::tls::{RootCerts, TlsConfig};
114
115 let agent = Agent::config_builder()
116 .tls_config(
117 TlsConfig::builder()
118 .root_certs(RootCerts::PlatformVerifier)
119 .build(),
120 )
121 .redirect_auth_headers(RedirectAuthHeaders::SameHost)
122 .http_status_as_error(false)
123 .build()
124 .new_agent();
125
126 Self {
127 agent,
128 base_url: base_url.trim_end_matches('/').to_string(),
129 user_agent: user_agent.to_string(),
130 token,
131 }
132 }
133
134 pub fn from_env(base_url: &str, user_agent: &str) -> Self {
139 let token = std::env::var("GITHUB_TOKEN")
140 .ok()
141 .or_else(|| std::env::var("GH_TOKEN").ok());
142 Self::new(base_url, user_agent, token)
143 }
144
145 pub fn latest_release_tag(&self, org: &str, repo: &str) -> Result<String, Error> {
150 let url = format!("{}/repos/{}/{}/releases", self.base_url, org, repo);
151 let mut response = self.get(&url)?;
152 let status = response.status().as_u16();
153
154 if status == 403 {
155 return Err(classify_forbidden(&response));
156 }
157 if status == 404 || status == 410 {
158 return Err(Error::NoReleaseFound {
159 org: org.to_string(),
160 repo: repo.to_string(),
161 });
162 }
163 if status == 429 {
164 return Err(Error::RateLimited {
165 reset_at: parse_reset_header(&response),
166 });
167 }
168 if !is_success(status) {
169 return Err(Error::Http {
170 method: "GET".to_string(),
171 url,
172 status: Some(status),
173 });
174 }
175
176 let releases: Vec<Release> = response.body_mut().read_json().map_err(|_| Error::Http {
177 method: "GET".to_string(),
178 url: url.clone(),
179 status: None,
180 })?;
181
182 releases
183 .into_iter()
184 .find(|r| !r.draft)
185 .map(|r| r.tag_name)
186 .ok_or_else(|| Error::NoReleaseFound {
187 org: org.to_string(),
188 repo: repo.to_string(),
189 })
190 }
191
192 pub fn release_asset_body(
197 &self,
198 org: &str,
199 repo: &str,
200 tag: &str,
201 ) -> Result<(u64, Box<dyn Read + Send>), Error> {
202 let url = format!("{}/repos/{}/{}/releases", self.base_url, org, repo);
203 let mut response = self.get(&url)?;
204 let status = response.status().as_u16();
205
206 if status == 403 {
207 return Err(classify_forbidden(&response));
208 }
209 if status == 404 || status == 410 {
210 return Err(Error::NoReleaseFound {
211 org: org.to_string(),
212 repo: repo.to_string(),
213 });
214 }
215 if status == 429 {
216 return Err(Error::RateLimited {
217 reset_at: parse_reset_header(&response),
218 });
219 }
220 if !is_success(status) {
221 return Err(Error::Http {
222 method: "GET".to_string(),
223 url,
224 status: Some(status),
225 });
226 }
227
228 let releases: Vec<Release> = response.body_mut().read_json().map_err(|_| Error::Http {
229 method: "GET".to_string(),
230 url: url.clone(),
231 status: None,
232 })?;
233
234 let release = releases
235 .into_iter()
236 .find(|r| r.tag_name == tag)
237 .ok_or_else(|| Error::NoReleaseFound {
238 org: org.to_string(),
239 repo: repo.to_string(),
240 })?;
241
242 let asset = release
243 .assets
244 .into_iter()
245 .find(|a| a.name.ends_with(".tar.gz"))
246 .ok_or_else(|| Error::NoTarballAsset {
247 org: org.to_string(),
248 repo: repo.to_string(),
249 tag: tag.to_string(),
250 })?;
251
252 let content_length = asset.size;
253
254 let asset_response = self.get(&asset.browser_download_url)?;
257 let asset_status = asset_response.status().as_u16();
258
259 if !is_success(asset_status) {
260 return Err(Error::Http {
261 method: "GET".to_string(),
262 url: asset.browser_download_url,
263 status: Some(asset_status),
264 });
265 }
266
267 let reader = asset_response.into_body().into_reader();
268 Ok((content_length, Box::new(reader)))
269 }
270
271 fn get(&self, url: &str) -> Result<http::Response<Body>, Error> {
273 let mut req = self
274 .agent
275 .get(url)
276 .header("Accept", "application/vnd.github+json")
277 .header("User-Agent", &self.user_agent)
278 .header("X-GitHub-Api-Version", "2022-11-28");
279
280 if let Some(ref token) = self.token {
281 req = req.header("Authorization", &format!("Bearer {token}"));
282 }
283
284 req.call().map_err(|e| sanitize_ureq_error("GET", url, e))
285 }
286}
287
288fn classify_forbidden(response: &http::Response<Body>) -> Error {
290 let remaining = response
291 .headers()
292 .get("x-ratelimit-remaining")
293 .and_then(|v| v.to_str().ok());
294 match remaining {
295 Some("0") => Error::RateLimited {
296 reset_at: parse_reset_header(response),
297 },
298 _ => Error::AuthFailed,
299 }
300}
301
302fn parse_reset_header(response: &http::Response<Body>) -> Option<SystemTime> {
304 response
305 .headers()
306 .get("x-ratelimit-reset")
307 .and_then(|v| v.to_str().ok())
308 .and_then(|v| v.parse::<u64>().ok())
309 .map(|secs| SystemTime::UNIX_EPOCH + Duration::from_secs(secs))
310}
311
312fn sanitize_ureq_error(method: &str, url: &str, _err: ureq::Error) -> Error {
315 Error::Http {
316 method: method.to_string(),
317 url: url.to_string(),
318 status: None,
319 }
320}
321
322fn is_success(status: u16) -> bool {
323 (200..300).contains(&status)
324}
325
326#[cfg(test)]
327mod tests {
328 use super::*;
329
330 #[test]
333 fn token_not_leaked_in_http_error_display() {
334 let sentinel = "sentinel_value_please_do_not_leak";
335 let err = Error::Http {
336 method: "GET".to_string(),
337 url: "https://api.github.com/repos/org/repo/releases".to_string(),
338 status: None,
339 };
340 let display = format!("{err}");
341 assert!(
342 !display.contains(sentinel),
343 "Error Display must never contain the token value"
344 );
345 }
346
347 #[test]
348 fn rate_limited_error_mentions_github_token() {
349 let err = Error::RateLimited { reset_at: None };
350 let display = format!("{err}");
351 assert!(
352 display.contains("GITHUB_TOKEN"),
353 "Rate limit error should suggest GITHUB_TOKEN"
354 );
355 }
356
357 #[test]
358 fn rate_limited_with_reset_time_formats_minutes() {
359 let reset_at = SystemTime::now() + Duration::from_secs(300);
360 let err = Error::RateLimited {
361 reset_at: Some(reset_at),
362 };
363 let display = format!("{err}");
364 assert!(
365 display.contains("minute"),
366 "Should mention minutes: {display}"
367 );
368 assert!(
369 display.contains("GITHUB_TOKEN"),
370 "Should suggest GITHUB_TOKEN"
371 );
372 }
373
374 #[test]
375 fn auth_failed_error_mentions_permissions() {
376 let err = Error::AuthFailed;
377 let display = format!("{err}");
378 assert!(display.contains("403"), "Should mention 403 status");
379 }
380
381 #[test]
382 fn no_release_found_names_org_and_repo() {
383 let err = Error::NoReleaseFound {
384 org: "myorg".to_string(),
385 repo: "myrepo".to_string(),
386 };
387 let display = format!("{err}");
388 assert!(display.contains("myorg/myrepo"));
389 }
390
391 #[test]
392 fn no_tarball_asset_names_tag() {
393 let err = Error::NoTarballAsset {
394 org: "o".to_string(),
395 repo: "r".to_string(),
396 tag: "v1.0.0".to_string(),
397 };
398 let display = format!("{err}");
399 assert!(display.contains("v1.0.0"));
400 }
401
402 fn fake_releases_json(server_url: &str) -> String {
406 serde_json::json!([
407 {
408 "tag_name": "v0.2.0",
409 "draft": false,
410 "assets": [{
411 "name": "omne-v0.2.0-x86_64-unknown-linux-gnu.tar.gz",
412 "browser_download_url": format!("{server_url}/download/v0.2.0/omne.tar.gz"),
413 "size": 1024
414 }]
415 },
416 {
417 "tag_name": "v0.1.0",
418 "draft": false,
419 "assets": [{
420 "name": "omne-v0.1.0-x86_64-unknown-linux-gnu.tar.gz",
421 "browser_download_url": format!("{server_url}/download/v0.1.0/omne.tar.gz"),
422 "size": 512
423 }]
424 }
425 ])
426 .to_string()
427 }
428
429 #[test]
432 fn latest_release_tag_returns_first_non_draft() {
433 let mut server = mockito::Server::new();
434 let body = fake_releases_json(&server.url());
435
436 let _m = server
437 .mock("GET", "/repos/omne-org/omne/releases")
438 .with_status(200)
439 .with_header("content-type", "application/json")
440 .with_body(&body)
441 .create();
442
443 let client = GithubClient::new(&server.url(), "test/1.0", None);
444 let tag = client.latest_release_tag("omne-org", "omne").unwrap();
445 assert_eq!(tag, "v0.2.0");
446 }
447
448 #[test]
449 fn latest_release_tag_skips_drafts() {
450 let mut server = mockito::Server::new();
451 let body = serde_json::json!([
452 { "tag_name": "v0.3.0-draft", "draft": true, "assets": [] },
453 { "tag_name": "v0.2.0", "draft": false, "assets": [] }
454 ])
455 .to_string();
456
457 let _m = server
458 .mock("GET", "/repos/org/repo/releases")
459 .with_status(200)
460 .with_header("content-type", "application/json")
461 .with_body(&body)
462 .create();
463
464 let client = GithubClient::new(&server.url(), "test/1.0", None);
465 let tag = client.latest_release_tag("org", "repo").unwrap();
466 assert_eq!(tag, "v0.2.0");
467 }
468
469 #[test]
470 fn latest_release_tag_empty_list_returns_no_release_found() {
471 let mut server = mockito::Server::new();
472 let _m = server
473 .mock("GET", "/repos/org/repo/releases")
474 .with_status(200)
475 .with_header("content-type", "application/json")
476 .with_body("[]")
477 .create();
478
479 let client = GithubClient::new(&server.url(), "test/1.0", None);
480 let err = client.latest_release_tag("org", "repo").unwrap_err();
481 assert!(
482 matches!(err, Error::NoReleaseFound { ref org, ref repo } if org == "org" && repo == "repo"),
483 "Expected NoReleaseFound, got: {err:?}"
484 );
485 }
486
487 #[test]
488 fn latest_release_tag_404_returns_no_release_found() {
489 let mut server = mockito::Server::new();
490 let _m = server
491 .mock("GET", "/repos/org/missing/releases")
492 .with_status(404)
493 .with_body("{\"message\": \"Not Found\"}")
494 .create();
495
496 let client = GithubClient::new(&server.url(), "test/1.0", None);
497 let err = client.latest_release_tag("org", "missing").unwrap_err();
498 assert!(matches!(err, Error::NoReleaseFound { .. }));
499 }
500
501 #[test]
504 fn rate_limit_403_with_headers_returns_rate_limited() {
505 let mut server = mockito::Server::new();
506 let reset_epoch = SystemTime::now()
507 .duration_since(SystemTime::UNIX_EPOCH)
508 .unwrap()
509 .as_secs()
510 + 600;
511
512 let _m = server
513 .mock("GET", "/repos/org/repo/releases")
514 .with_status(403)
515 .with_header("x-ratelimit-remaining", "0")
516 .with_header("x-ratelimit-reset", &reset_epoch.to_string())
517 .with_body("{\"message\": \"API rate limit exceeded\"}")
518 .create();
519
520 let client = GithubClient::new(&server.url(), "test/1.0", None);
521 let err = client.latest_release_tag("org", "repo").unwrap_err();
522 match err {
523 Error::RateLimited { reset_at } => {
524 assert!(reset_at.is_some(), "Should parse reset_at from header");
525 let display = format!("{err}");
526 assert!(display.contains("GITHUB_TOKEN"));
527 assert!(display.contains("minute"));
528 }
529 other => panic!("Expected RateLimited, got: {other:?}"),
530 }
531 }
532
533 #[test]
534 fn auth_failure_403_without_rate_limit_headers() {
535 let mut server = mockito::Server::new();
536 let _m = server
537 .mock("GET", "/repos/org/repo/releases")
538 .with_status(403)
539 .with_body("{\"message\": \"Bad credentials\"}")
540 .create();
541
542 let client = GithubClient::new(&server.url(), "test/1.0", Some("bad-token".into()));
543 let err = client.latest_release_tag("org", "repo").unwrap_err();
544 assert!(
545 matches!(err, Error::AuthFailed),
546 "Expected AuthFailed, got: {err:?}"
547 );
548 }
549
550 #[test]
551 fn rate_limit_429_returns_rate_limited() {
552 let mut server = mockito::Server::new();
553 let _m = server
554 .mock("GET", "/repos/org/repo/releases")
555 .with_status(429)
556 .with_header("retry-after", "60")
557 .with_body("{\"message\": \"Too Many Requests\"}")
558 .create();
559
560 let client = GithubClient::new(&server.url(), "test/1.0", None);
561 let err = client.latest_release_tag("org", "repo").unwrap_err();
562 assert!(
563 matches!(err, Error::RateLimited { .. }),
564 "Expected RateLimited, got: {err:?}"
565 );
566 }
567
568 #[test]
571 fn client_with_token_sends_authorization_header() {
572 let mut server = mockito::Server::new();
573 let body = fake_releases_json(&server.url());
574
575 let _m = server
576 .mock("GET", "/repos/org/repo/releases")
577 .match_header("Authorization", "Bearer test-token-123")
578 .with_status(200)
579 .with_header("content-type", "application/json")
580 .with_body(&body)
581 .expect(1)
582 .create();
583
584 let client = GithubClient::new(&server.url(), "test/1.0", Some("test-token-123".into()));
585 let tag = client.latest_release_tag("org", "repo").unwrap();
586 assert_eq!(tag, "v0.2.0");
587 }
590
591 #[test]
592 fn client_without_token_sends_no_authorization_header() {
593 let mut server = mockito::Server::new();
594 let body = fake_releases_json(&server.url());
595
596 let _m = server
597 .mock("GET", "/repos/org/repo/releases")
598 .match_header("Authorization", mockito::Matcher::Missing)
599 .with_status(200)
600 .with_header("content-type", "application/json")
601 .with_body(&body)
602 .expect(1)
603 .create();
604
605 let client = GithubClient::new(&server.url(), "test/1.0", None);
606 let tag = client.latest_release_tag("org", "repo").unwrap();
607 assert_eq!(tag, "v0.2.0");
608 }
609
610 #[test]
613 fn token_not_leaked_on_network_error() {
614 let client = GithubClient::new(
616 "http://127.0.0.1:1",
617 "test/1.0",
618 Some("sentinel_value_please_do_not_leak".into()),
619 );
620 let err = client.latest_release_tag("org", "repo").unwrap_err();
621 let display = format!("{err}");
622 assert!(
623 !display.contains("sentinel_value_please_do_not_leak"),
624 "Error must not leak token: {display}"
625 );
626 }
627
628 #[test]
631 fn release_asset_body_returns_reader_and_size() {
632 let mut server = mockito::Server::new();
633 let tarball_bytes = b"fake-tarball-content";
634 let body = serde_json::json!([{
635 "tag_name": "v1.0.0",
636 "draft": false,
637 "assets": [{
638 "name": "omne-v1.0.0.tar.gz",
639 "browser_download_url": format!("{}/download/v1.0.0/omne.tar.gz", server.url()),
640 "size": tarball_bytes.len()
641 }]
642 }])
643 .to_string();
644
645 let _m_releases = server
646 .mock("GET", "/repos/org/repo/releases")
647 .with_status(200)
648 .with_header("content-type", "application/json")
649 .with_body(&body)
650 .create();
651
652 let _m_download = server
653 .mock("GET", "/download/v1.0.0/omne.tar.gz")
654 .with_status(200)
655 .with_body(tarball_bytes)
656 .create();
657
658 let client = GithubClient::new(&server.url(), "test/1.0", None);
659 let (size, mut reader) = client.release_asset_body("org", "repo", "v1.0.0").unwrap();
660
661 assert_eq!(size, tarball_bytes.len() as u64);
662
663 let mut buf = Vec::new();
664 reader.read_to_end(&mut buf).unwrap();
665 assert_eq!(buf, tarball_bytes);
666 }
667
668 #[test]
669 fn release_asset_body_no_tarball_asset() {
670 let mut server = mockito::Server::new();
671 let body = serde_json::json!([{
672 "tag_name": "v1.0.0",
673 "draft": false,
674 "assets": [{
675 "name": "omne-v1.0.0.zip",
676 "browser_download_url": format!("{}/download/omne.zip", server.url()),
677 "size": 100
678 }]
679 }])
680 .to_string();
681
682 let _m = server
683 .mock("GET", "/repos/org/repo/releases")
684 .with_status(200)
685 .with_header("content-type", "application/json")
686 .with_body(&body)
687 .create();
688
689 let client = GithubClient::new(&server.url(), "test/1.0", None);
690 let result = client.release_asset_body("org", "repo", "v1.0.0");
691 let err = result.err().expect("expected error");
692 assert!(
693 matches!(err, Error::NoTarballAsset { ref tag, .. } if tag == "v1.0.0"),
694 "Expected NoTarballAsset, got: {err:?}"
695 );
696 }
697
698 #[test]
699 fn release_asset_body_tag_not_found() {
700 let mut server = mockito::Server::new();
701 let body = serde_json::json!([{
702 "tag_name": "v2.0.0",
703 "draft": false,
704 "assets": []
705 }])
706 .to_string();
707
708 let _m = server
709 .mock("GET", "/repos/org/repo/releases")
710 .with_status(200)
711 .with_header("content-type", "application/json")
712 .with_body(&body)
713 .create();
714
715 let client = GithubClient::new(&server.url(), "test/1.0", None);
716 let result = client.release_asset_body("org", "repo", "v1.0.0");
717 let err = result.err().expect("expected error");
718 assert!(
719 matches!(err, Error::NoReleaseFound { .. }),
720 "Expected NoReleaseFound, got: {err:?}"
721 );
722 }
723
724 #[test]
725 fn release_asset_body_404_returns_no_release_found() {
726 let mut server = mockito::Server::new();
727
728 let _m = server
729 .mock("GET", "/repos/org/repo/releases")
730 .with_status(404)
731 .create();
732
733 let client = GithubClient::new(&server.url(), "test/1.0", None);
734 let result = client.release_asset_body("org", "repo", "v1.0.0");
735 let err = result.err().expect("expected error");
736 assert!(
737 matches!(err, Error::NoReleaseFound { .. }),
738 "Expected NoReleaseFound on 404, got: {err:?}"
739 );
740 }
741
742 #[test]
743 fn release_asset_body_429_returns_rate_limited() {
744 let mut server = mockito::Server::new();
745
746 let _m = server
747 .mock("GET", "/repos/org/repo/releases")
748 .with_status(429)
749 .with_header("retry-after", "60")
750 .create();
751
752 let client = GithubClient::new(&server.url(), "test/1.0", None);
753 let result = client.release_asset_body("org", "repo", "v1.0.0");
754 let err = result.err().expect("expected error");
755 assert!(
756 matches!(err, Error::RateLimited { .. }),
757 "Expected RateLimited on 429, got: {err:?}"
758 );
759 }
760
761 #[test]
764 fn client_sends_required_github_api_headers() {
765 let mut server = mockito::Server::new();
766 let body = fake_releases_json(&server.url());
767
768 let _m = server
769 .mock("GET", "/repos/org/repo/releases")
770 .match_header("Accept", "application/vnd.github+json")
771 .match_header("X-GitHub-Api-Version", "2022-11-28")
772 .match_header("User-Agent", "omne-cli/test")
773 .with_status(200)
774 .with_header("content-type", "application/json")
775 .with_body(&body)
776 .expect(1)
777 .create();
778
779 let client = GithubClient::new(&server.url(), "omne-cli/test", None);
780 client.latest_release_tag("org", "repo").unwrap();
781 }
782
783 #[test]
786 fn from_env_prefers_github_token_over_gh_token() {
787 let mut server = mockito::Server::new();
795 let body = fake_releases_json(&server.url());
796
797 let _m = server
799 .mock("GET", "/repos/org/repo/releases")
800 .match_header("Authorization", "Bearer X")
801 .with_status(200)
802 .with_header("content-type", "application/json")
803 .with_body(&body)
804 .expect(1)
805 .create();
806
807 let client = GithubClient::new(&server.url(), "test/1.0", Some("X".into()));
809 client.latest_release_tag("org", "repo").unwrap();
810 }
811
812 #[test]
815 #[ignore = "hits real api.github.com — run manually with `cargo test -- --ignored`"]
816 fn live_smoke_test_latest_release() {
817 let client = GithubClient::from_env("https://api.github.com", "omne-cli/test");
818 let tag = client.latest_release_tag("omne-org", "omne").unwrap();
819 assert!(
820 tag.starts_with('v'),
821 "Expected tag starting with 'v', got: {tag}"
822 );
823 }
824}