Skip to main content

yosh_plugin_manager/
github.rs

1use std::fs;
2use std::io::{Read, Write};
3use std::path::Path;
4
5use ureq::Agent;
6use ureq::tls::{RootCerts, TlsConfig};
7
8/// Error type for GitHub API requests made through `get_json`.
9#[derive(Debug)]
10enum GitHubApiError {
11    /// HTTP response with non-2xx status code (excluding 403/429).
12    HttpStatus(u16),
13    /// HTTP 403 or 429 — likely rate-limited, or auth-rejected when a
14    /// token is set. Surfaced separately so callers can attach a
15    /// "set YOSH_GITHUB_TOKEN" hint when no token is configured.
16    RateLimited(u16),
17    /// Network/transport error (DNS, connection, timeout).
18    Network(String),
19    /// Response body could not be read or parsed as JSON.
20    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
37/// Build a ureq `Agent` that delegates certificate verification to the
38/// platform's trust store. The platform verifier (`rustls-platform-verifier`)
39/// performs AIA fetching on macOS/Windows, which is required to complete
40/// the chain for GitHub release assets that redirect to Azure Blob
41/// endpoints. See https://github.com/k-ymmt/yosh/issues/2.
42fn 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
53/// GitHub API client for fetching release information and downloading assets.
54pub struct GitHubClient {
55    base_url: String,
56    token: Option<String>,
57    agent: Agent,
58}
59
60impl GitHubClient {
61    /// Create a new client, reading auth token from `YOSH_GITHUB_TOKEN`
62    /// (preferred) or `GITHUB_TOKEN`. The legacy `KISH_GITHUB_TOKEN` env
63    /// var was removed as part of the kish→yosh rename cleanup in
64    /// v0.2.0.
65    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    /// Look up a GitHub release by tag, trying `v{version}` first then `{version}`.
115    /// Returns the download URL of the named asset.
116    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                // Fallback to bare version tag
128                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    /// Download a file from an HTTPS URL to a local path. Rejects non-HTTPS URLs.
163    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    /// Get the latest release tag for a repo, stripping a leading `v` prefix.
200    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/// Test-only client that uses a custom base URL (for mockito tests).
232#[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    /// Unwrap to a plain `GitHubClient` so callers that need the
278    /// concrete type (e.g. `update::update`'s signature) can be driven
279    /// against a mockito server in tests.
280    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        // v-prefixed tag returns 404
364        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        // bare version tag succeeds
371        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        // Both v-prefix and bare-version tag attempts return 429.
573        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        // Both v-prefix and bare-version tag attempts return 429 (rate-limited).
606        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}