1use std::fs;
2use std::io::{Read, Write};
3use std::path::Path;
4
5use ureq::Agent;
6use ureq::tls::{RootCerts, TlsConfig};
7
8#[derive(Debug)]
10enum GitHubApiError {
11 HttpStatus(u16),
13 RateLimited(u16),
17 Network(String),
19 Parse(String),
21}
22
23impl std::fmt::Display for GitHubApiError {
24 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
25 match self {
26 Self::HttpStatus(code) => write!(f, "HTTP {}", code),
27 Self::RateLimited(code) => write!(f, "HTTP {} (rate-limited or unauthorized)", code),
28 Self::Network(msg) => write!(f, "request failed: {}", msg),
29 Self::Parse(msg) => write!(f, "{}", msg),
30 }
31 }
32}
33
34const RATE_LIMIT_HINT: &str =
35 "hint: set YOSH_GITHUB_TOKEN or GITHUB_TOKEN to raise the rate limit (60 -> 5000 req/hour)";
36
37fn build_agent() -> Agent {
43 ureq::Agent::config_builder()
44 .tls_config(
45 TlsConfig::builder()
46 .root_certs(RootCerts::PlatformVerifier)
47 .build(),
48 )
49 .build()
50 .new_agent()
51}
52
53pub struct GitHubClient {
55 base_url: String,
56 token: Option<String>,
57 agent: Agent,
58}
59
60impl GitHubClient {
61 pub fn new() -> Self {
66 let token = std::env::var("YOSH_GITHUB_TOKEN")
67 .ok()
68 .or_else(|| std::env::var("GITHUB_TOKEN").ok());
69 Self {
70 base_url: "https://api.github.com".to_string(),
71 token,
72 agent: build_agent(),
73 }
74 }
75
76 fn get_json(&self, url: &str) -> Result<serde_json::Value, GitHubApiError> {
77 let mut req = self
78 .agent
79 .get(url)
80 .header("User-Agent", "yosh-plugin-manager")
81 .header("Accept", "application/vnd.github.v3+json");
82 if let Some(token) = &self.token {
83 req = req.header("Authorization", format!("Bearer {}", token));
84 }
85 let body = req
86 .call()
87 .map_err(|e| match &e {
88 ureq::Error::StatusCode(code) if *code == 403 || *code == 429 => {
89 GitHubApiError::RateLimited(*code)
90 }
91 ureq::Error::StatusCode(code) => GitHubApiError::HttpStatus(*code),
92 _ => GitHubApiError::Network(e.to_string()),
93 })?
94 .body_mut()
95 .read_to_string()
96 .map_err(|e| GitHubApiError::Parse(format!("failed to read body: {}", e)))?;
97 serde_json::from_str(&body)
98 .map_err(|e| GitHubApiError::Parse(format!("failed to parse JSON: {}", e)))
99 }
100
101 fn release_json(
102 &self,
103 owner: &str,
104 repo: &str,
105 tag: &str,
106 ) -> Result<serde_json::Value, GitHubApiError> {
107 let url = format!(
108 "{}/repos/{}/{}/releases/tags/{}",
109 self.base_url, owner, repo, tag
110 );
111 self.get_json(&url)
112 }
113
114 pub fn find_asset_url(
117 &self,
118 owner: &str,
119 repo: &str,
120 version: &str,
121 asset_name: &str,
122 ) -> Result<String, String> {
123 let v_tag = format!("v{}", version);
124 let release = match self.release_json(owner, repo, &v_tag) {
125 Ok(r) => r,
126 Err(_) => {
127 self.release_json(owner, repo, version)
129 .map_err(|e| match e {
130 GitHubApiError::HttpStatus(404) => format!(
131 "release not found for {}/{} (tried tags '{}' and '{}')",
132 owner, repo, v_tag, version
133 ),
134 other @ GitHubApiError::RateLimited(_) if self.token.is_none() => format!(
135 "failed to fetch release for {}/{} (tried tags '{}' and '{}'): {}\n {}",
136 owner, repo, v_tag, version, other, RATE_LIMIT_HINT
137 ),
138 other => format!(
139 "failed to fetch release for {}/{} (tried tags '{}' and '{}'): {}",
140 owner, repo, v_tag, version, other
141 ),
142 })?
143 }
144 };
145
146 let assets = release["assets"]
147 .as_array()
148 .ok_or_else(|| "release has no assets array".to_string())?;
149
150 for asset in assets {
151 if asset["name"].as_str() == Some(asset_name) {
152 let url = asset["browser_download_url"]
153 .as_str()
154 .ok_or_else(|| "asset has no browser_download_url".to_string())?;
155 return Ok(url.to_string());
156 }
157 }
158
159 Err(format!("asset '{}' not found in release", asset_name))
160 }
161
162 pub fn download(&self, url: &str, dest: &Path) -> Result<(), String> {
164 if !url.starts_with("https://") {
165 return Err(format!("refusing non-HTTPS URL: {}", url));
166 }
167
168 let mut req = self
169 .agent
170 .get(url)
171 .header("User-Agent", "yosh-plugin-manager")
172 .header("Accept", "application/vnd.github.v3+json");
173 if let Some(token) = &self.token {
174 req = req.header("Authorization", format!("Bearer {}", token));
175 }
176 let mut response = req
177 .call()
178 .map_err(|e| format!("download request failed: {}", e))?;
179
180 let mut file = fs::File::create(dest)
181 .map_err(|e| format!("failed to create {}: {}", dest.display(), e))?;
182
183 let mut reader = response.body_mut().as_reader();
184 let mut buf = [0u8; 8192];
185 loop {
186 let n = reader
187 .read(&mut buf)
188 .map_err(|e| format!("failed to read response body: {}", e))?;
189 if n == 0 {
190 break;
191 }
192 file.write_all(&buf[..n])
193 .map_err(|e| format!("failed to write to {}: {}", dest.display(), e))?;
194 }
195
196 Ok(())
197 }
198
199 pub fn latest_version(&self, owner: &str, repo: &str) -> Result<String, String> {
201 let url = format!("{}/repos/{}/{}/releases/latest", self.base_url, owner, repo);
202 let json = self.get_json(&url).map_err(|e| match e {
203 GitHubApiError::HttpStatus(404) => format!(
204 "no releases found for {}/{}: publish a GitHub Release first",
205 owner, repo
206 ),
207 other @ GitHubApiError::RateLimited(_) if self.token.is_none() => format!(
208 "failed to fetch latest release for {}/{}: {}\n {}",
209 owner, repo, other, RATE_LIMIT_HINT
210 ),
211 other => format!(
212 "failed to fetch latest release for {}/{}: {}",
213 owner, repo, other
214 ),
215 })?;
216
217 let tag = json["tag_name"]
218 .as_str()
219 .ok_or_else(|| "release has no tag_name".to_string())?;
220
221 Ok(tag.trim_start_matches('v').to_string())
222 }
223}
224
225impl Default for GitHubClient {
226 fn default() -> Self {
227 Self::new()
228 }
229}
230
231#[cfg(test)]
233pub struct GitHubClientWithBase {
234 inner: GitHubClient,
235}
236
237#[cfg(test)]
238impl GitHubClientWithBase {
239 pub fn new(base_url: &str) -> Self {
240 Self {
241 inner: GitHubClient {
242 base_url: base_url.to_string(),
243 token: None,
244 agent: build_agent(),
245 },
246 }
247 }
248
249 pub fn with_token(base_url: &str, token: &str) -> Self {
250 Self {
251 inner: GitHubClient {
252 base_url: base_url.to_string(),
253 token: Some(token.to_string()),
254 agent: build_agent(),
255 },
256 }
257 }
258
259 pub fn find_asset_url(
260 &self,
261 owner: &str,
262 repo: &str,
263 version: &str,
264 asset_name: &str,
265 ) -> Result<String, String> {
266 self.inner.find_asset_url(owner, repo, version, asset_name)
267 }
268
269 pub fn latest_version(&self, owner: &str, repo: &str) -> Result<String, String> {
270 self.inner.latest_version(owner, repo)
271 }
272
273 pub fn download(&self, url: &str, dest: &Path) -> Result<(), String> {
274 self.inner.download(url, dest)
275 }
276
277 pub fn into_client(self) -> GitHubClient {
281 self.inner
282 }
283}
284
285#[cfg(test)]
286mod tests {
287 use super::*;
288
289 fn make_release_json(assets: &[(&str, &str)]) -> String {
290 let assets_json: Vec<String> = assets
291 .iter()
292 .map(|(name, url)| {
293 format!(
294 r#"{{"name": "{}", "browser_download_url": "{}"}}"#,
295 name, url
296 )
297 })
298 .collect();
299 format!(
300 r#"{{"tag_name": "v1.2.3", "assets": [{}]}}"#,
301 assets_json.join(", ")
302 )
303 }
304
305 #[test]
306 fn parse_release_json_finds_asset() {
307 let json: serde_json::Value = serde_json::from_str(&make_release_json(&[(
308 "libfoo-linux-x86_64.so",
309 "https://example.com/libfoo-linux-x86_64.so",
310 )]))
311 .unwrap();
312 let url = json["assets"][0]["browser_download_url"].as_str().unwrap();
313 assert_eq!(url, "https://example.com/libfoo-linux-x86_64.so");
314 }
315
316 #[test]
317 fn parse_release_json_asset_not_found() {
318 let json: serde_json::Value = serde_json::from_str(&make_release_json(&[(
319 "other-asset.so",
320 "https://example.com/other.so",
321 )]))
322 .unwrap();
323 let assets = json["assets"].as_array().unwrap();
324 let found = assets
325 .iter()
326 .any(|a| a["name"].as_str() == Some("libfoo-linux-x86_64.so"));
327 assert!(!found);
328 }
329
330 #[test]
331 fn download_rejects_non_https() {
332 let client = GitHubClient::new();
333 let tmp = tempfile::NamedTempFile::new().unwrap();
334 let err = client
335 .download("http://example.com/file", tmp.path())
336 .unwrap_err();
337 assert!(
338 err.contains("non-HTTPS"),
339 "expected non-HTTPS error, got: {}",
340 err
341 );
342 }
343
344 #[test]
345 fn download_rejects_ftp_url() {
346 let client = GitHubClient::new();
347 let tmp = tempfile::NamedTempFile::new().unwrap();
348 let err = client
349 .download("ftp://example.com/file", tmp.path())
350 .unwrap_err();
351 assert!(
352 err.contains("non-HTTPS"),
353 "expected non-HTTPS error, got: {}",
354 err
355 );
356 }
357
358 #[test]
359 fn find_asset_url_v_prefix_fallback() {
360 let mut server = mockito::Server::new();
361 let base = server.url();
362
363 let _m1 = server
365 .mock("GET", "/repos/owner/repo/releases/tags/v1.0.0")
366 .with_status(404)
367 .with_body(r#"{"message": "Not Found"}"#)
368 .create();
369
370 let body = make_release_json(&[("myasset.so", "https://dl.example.com/myasset.so")]);
372 let _m2 = server
373 .mock("GET", "/repos/owner/repo/releases/tags/1.0.0")
374 .with_status(200)
375 .with_header("content-type", "application/json")
376 .with_body(&body)
377 .create();
378
379 let client = GitHubClientWithBase::new(&base);
380 let url = client
381 .find_asset_url("owner", "repo", "1.0.0", "myasset.so")
382 .unwrap();
383 assert_eq!(url, "https://dl.example.com/myasset.so");
384 }
385
386 #[test]
387 fn find_asset_url_v_prefix_succeeds() {
388 let mut server = mockito::Server::new();
389 let base = server.url();
390
391 let body = make_release_json(&[("myasset.so", "https://dl.example.com/myasset.so")]);
392 let _m = server
393 .mock("GET", "/repos/owner/repo/releases/tags/v2.0.0")
394 .with_status(200)
395 .with_header("content-type", "application/json")
396 .with_body(&body)
397 .create();
398
399 let client = GitHubClientWithBase::new(&base);
400 let url = client
401 .find_asset_url("owner", "repo", "2.0.0", "myasset.so")
402 .unwrap();
403 assert_eq!(url, "https://dl.example.com/myasset.so");
404 }
405
406 #[test]
407 fn find_asset_url_asset_not_found() {
408 let mut server = mockito::Server::new();
409 let base = server.url();
410
411 let body = make_release_json(&[("other.so", "https://dl.example.com/other.so")]);
412 let _m = server
413 .mock("GET", "/repos/owner/repo/releases/tags/v3.0.0")
414 .with_status(200)
415 .with_header("content-type", "application/json")
416 .with_body(&body)
417 .create();
418
419 let client = GitHubClientWithBase::new(&base);
420 let err = client
421 .find_asset_url("owner", "repo", "3.0.0", "nonexistent.so")
422 .unwrap_err();
423 assert!(
424 err.contains("not found"),
425 "expected not found error, got: {}",
426 err
427 );
428 }
429
430 #[test]
431 fn latest_version_strips_v_prefix() {
432 let mut server = mockito::Server::new();
433 let base = server.url();
434
435 let _m = server
436 .mock("GET", "/repos/owner/repo/releases/latest")
437 .with_status(200)
438 .with_header("content-type", "application/json")
439 .with_body(r#"{"tag_name": "v4.5.6"}"#)
440 .create();
441
442 let client = GitHubClientWithBase::new(&base);
443 let version = client.latest_version("owner", "repo").unwrap();
444 assert_eq!(version, "4.5.6");
445 }
446
447 #[test]
448 fn latest_version_no_v_prefix() {
449 let mut server = mockito::Server::new();
450 let base = server.url();
451
452 let _m = server
453 .mock("GET", "/repos/owner/repo/releases/latest")
454 .with_status(200)
455 .with_header("content-type", "application/json")
456 .with_body(r#"{"tag_name": "1.0.0"}"#)
457 .create();
458
459 let client = GitHubClientWithBase::new(&base);
460 let version = client.latest_version("owner", "repo").unwrap();
461 assert_eq!(version, "1.0.0");
462 }
463
464 #[test]
465 fn latest_version_no_releases_gives_helpful_error() {
466 let mut server = mockito::Server::new();
467 let base = server.url();
468
469 let _m = server
470 .mock("GET", "/repos/owner/repo/releases/latest")
471 .with_status(404)
472 .with_body(r#"{"message": "Not Found"}"#)
473 .create();
474
475 let client = GitHubClientWithBase::new(&base);
476 let err = client.latest_version("owner", "repo").unwrap_err();
477 assert!(
478 err.contains("no releases found for owner/repo"),
479 "expected helpful error, got: {}",
480 err
481 );
482 assert!(
483 err.contains("publish a GitHub Release first"),
484 "expected hint about publishing a release, got: {}",
485 err
486 );
487 }
488
489 #[test]
490 fn find_asset_url_both_tags_404_gives_helpful_error() {
491 let mut server = mockito::Server::new();
492 let base = server.url();
493
494 let _m1 = server
495 .mock("GET", "/repos/owner/repo/releases/tags/v1.0.0")
496 .with_status(404)
497 .with_body(r#"{"message": "Not Found"}"#)
498 .create();
499
500 let _m2 = server
501 .mock("GET", "/repos/owner/repo/releases/tags/1.0.0")
502 .with_status(404)
503 .with_body(r#"{"message": "Not Found"}"#)
504 .create();
505
506 let client = GitHubClientWithBase::new(&base);
507 let err = client
508 .find_asset_url("owner", "repo", "1.0.0", "myasset.so")
509 .unwrap_err();
510 assert!(
511 err.contains("release not found for owner/repo"),
512 "expected helpful error, got: {}",
513 err
514 );
515 assert!(
516 err.contains("v1.0.0"),
517 "expected tried tags in error, got: {}",
518 err
519 );
520 }
521
522 #[test]
523 fn latest_version_403_without_token_includes_hint() {
524 let mut server = mockito::Server::new();
525 let base = server.url();
526
527 let _m = server
528 .mock("GET", "/repos/owner/repo/releases/latest")
529 .with_status(403)
530 .with_body(r#"{"message": "API rate limit exceeded"}"#)
531 .create();
532
533 let client = GitHubClientWithBase::new(&base);
534 let err = client.latest_version("owner", "repo").unwrap_err();
535 assert!(
536 err.contains("YOSH_GITHUB_TOKEN"),
537 "expected hint mentioning YOSH_GITHUB_TOKEN, got: {}",
538 err
539 );
540 }
541
542 #[test]
543 fn latest_version_403_with_token_no_hint() {
544 let mut server = mockito::Server::new();
545 let base = server.url();
546
547 let _m = server
548 .mock("GET", "/repos/owner/repo/releases/latest")
549 .with_status(403)
550 .with_body(r#"{"message": "Bad credentials"}"#)
551 .create();
552
553 let client = GitHubClientWithBase::with_token(&base, "fake-token");
554 let err = client.latest_version("owner", "repo").unwrap_err();
555 assert!(
556 !err.contains("YOSH_GITHUB_TOKEN"),
557 "should not suggest setting a token when one is already set, got: {}",
558 err
559 );
560 assert!(
561 err.contains("HTTP 403"),
562 "should still surface the HTTP status, got: {}",
563 err
564 );
565 }
566
567 #[test]
568 fn find_asset_url_429_with_token_no_hint() {
569 let mut server = mockito::Server::new();
570 let base = server.url();
571
572 let _m1 = server
574 .mock("GET", "/repos/owner/repo/releases/tags/v1.0.0")
575 .with_status(429)
576 .with_body(r#"{"message": "Too many requests"}"#)
577 .create();
578 let _m2 = server
579 .mock("GET", "/repos/owner/repo/releases/tags/1.0.0")
580 .with_status(429)
581 .with_body(r#"{"message": "Too many requests"}"#)
582 .create();
583
584 let client = GitHubClientWithBase::with_token(&base, "fake-token");
585 let err = client
586 .find_asset_url("owner", "repo", "1.0.0", "asset.wasm")
587 .unwrap_err();
588 assert!(
589 !err.contains("YOSH_GITHUB_TOKEN"),
590 "should not suggest setting a token when one is already set, got: {}",
591 err
592 );
593 assert!(
594 err.contains("HTTP 429"),
595 "should still surface the HTTP status, got: {}",
596 err
597 );
598 }
599
600 #[test]
601 fn find_asset_url_429_without_token_includes_hint() {
602 let mut server = mockito::Server::new();
603 let base = server.url();
604
605 let _m1 = server
607 .mock("GET", "/repos/owner/repo/releases/tags/v1.0.0")
608 .with_status(429)
609 .with_body(r#"{"message": "Too many requests"}"#)
610 .create();
611 let _m2 = server
612 .mock("GET", "/repos/owner/repo/releases/tags/1.0.0")
613 .with_status(429)
614 .with_body(r#"{"message": "Too many requests"}"#)
615 .create();
616
617 let client = GitHubClientWithBase::new(&base);
618 let err = client
619 .find_asset_url("owner", "repo", "1.0.0", "asset.wasm")
620 .unwrap_err();
621 assert!(
622 err.contains("YOSH_GITHUB_TOKEN"),
623 "expected rate-limit hint, got: {}",
624 err
625 );
626 }
627}