Skip to main content

update_rs/source/
github.rs

1use super::Source;
2#[cfg(not(feature = "tracing"))]
3use log::debug;
4#[cfg(feature = "tracing")]
5use tracing::debug;
6
7use crate::{Error, Release, ReleaseVariant, glob};
8use futures_util::StreamExt;
9use human_errors::ResultExt;
10use reqwest::StatusCode;
11use serde::Deserialize;
12use sha2::{Digest, Sha256};
13use std::io::Write;
14
15/// The `User-Agent` header the crate's default client sends with every request,
16/// built from this crate's version at compile time. GitHub requires a
17/// `User-Agent` on all API requests.
18const USER_AGENT: &str = concat!("SierraSoftworks/update-rs v", env!("CARGO_PKG_VERSION"));
19
20/// Build the crate's default HTTP client, with its [`USER_AGENT`] set at the
21/// client level so it applies to every request — and is replaced wholesale when
22/// a caller supplies their own client via [`GitHubSource::with_client`].
23fn default_client() -> reqwest::Client {
24    reqwest::Client::builder()
25        .user_agent(USER_AGENT)
26        .build()
27        .unwrap_or_else(|_| reqwest::Client::new())
28}
29
30/// A [`Source`] which lists and downloads releases from a GitHub repository's
31/// [releases API](https://docs.github.com/en/rest/releases).
32///
33/// The asset to download is selected by matching a **glob pattern** against each
34/// release's asset file names, so your project can name its release assets
35/// however it likes — there is no required naming scheme. The pattern is the
36/// second argument to [`new`](GitHubSource::new):
37///
38/// ```
39/// use std::env::consts::{ARCH, EXE_SUFFIX, OS};
40/// use update_rs::GitHubSource;
41///
42/// let source = GitHubSource::new(
43///     "sierrasoftworks/git-tool",
44///     format!("git-tool-{OS}-{ARCH}{EXE_SUFFIX}"),
45/// )
46/// .with_release_tag_prefix("v");
47/// ```
48///
49/// The [`naming`](crate::naming) helpers build common patterns for you, e.g.
50/// [`naming::go`](crate::naming::go) (`git-tool-linux-amd64`) or
51/// [`naming::rust`](crate::naming::rust) (`git-tool-x86_64-unknown-linux-gnu`).
52///
53/// The pattern must match the asset's **whole** file name (it is anchored at
54/// both ends), so an exact name won't accidentally select a `.sha256` checksum
55/// or `.sig` sidecar. It supports `*` (any sequence) and `?` (single character)
56/// wildcards; every other character matches literally. `release_tag_prefix` is
57/// stripped from each Git tag before it is parsed as a
58/// [semantic version](semver::Version), and tags that don't parse afterwards are
59/// ignored.
60///
61/// When GitHub reports a SHA-256 [digest](https://docs.github.com/en/rest/releases/assets)
62/// for an asset, the downloaded bytes are verified against it before the update
63/// proceeds, so a corrupted or tampered download is rejected.
64pub struct GitHubSource {
65    github_endpoint: String,
66    github_api: String,
67    repo: String,
68    asset_pattern: String,
69    release_tag_prefix: String,
70
71    client: reqwest::Client,
72}
73
74impl GitHubSource {
75    /// Create a source for the `owner/name` `repo` (e.g.
76    /// `"sierrasoftworks/git-tool"`), selecting the asset to download with the
77    /// glob `asset_pattern` (e.g. `"git-tool-linux-amd64"` or `"*-linux-amd64"`).
78    ///
79    /// See the [`naming`](crate::naming) module for helpers that build a pattern
80    /// for the current platform.
81    pub fn new(repo: impl Into<String>, asset_pattern: impl Into<String>) -> Self {
82        Self {
83            github_endpoint: "https://github.com".to_string(),
84            github_api: "https://api.github.com".to_string(),
85            repo: repo.into(),
86            asset_pattern: asset_pattern.into(),
87            release_tag_prefix: String::new(),
88
89            client: default_client(),
90        }
91    }
92
93    /// Use a caller-provided [`reqwest::Client`] for every request instead of the
94    /// crate's default.
95    ///
96    /// This lets the consuming application apply its own client configuration —
97    /// proxy, TLS, timeouts, a custom `User-Agent`, default authentication
98    /// headers, and so on. The crate does **not** add its own `User-Agent` to a
99    /// caller-supplied client, so the client should set one itself (GitHub
100    /// requires a `User-Agent` on every request), for example via
101    /// [`reqwest::ClientBuilder::user_agent`].
102    pub fn with_client(mut self, client: reqwest::Client) -> Self {
103        self.client = client;
104        self
105    }
106
107    /// Strip `prefix` from each release's Git tag before parsing it as a
108    /// [semantic version](semver::Version) (e.g. `"v"` for `vX.Y.Z` tags).
109    pub fn with_release_tag_prefix(mut self, prefix: &str) -> Self {
110        self.release_tag_prefix = prefix.to_string();
111        self
112    }
113
114    /// Override the GitHub web (`web`) and API (`api`) endpoints. This is
115    /// primarily useful for pointing the source at a GitHub Enterprise instance
116    /// or a mock server in tests. Trailing slashes are trimmed.
117    pub fn with_github_endpoints(mut self, web: &str, api: &str) -> Self {
118        self.github_endpoint = web.trim_end_matches('/').to_string();
119        self.github_api = api.trim_end_matches('/').to_string();
120        self
121    }
122}
123
124impl Default for GitHubSource {
125    fn default() -> Self {
126        GitHubSource::new("", "*")
127    }
128}
129
130impl std::fmt::Debug for GitHubSource {
131    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
132        write!(f, "GitHub - {} ({})", &self.repo, &self.asset_pattern)
133    }
134}
135
136#[async_trait::async_trait]
137impl Source for GitHubSource {
138    async fn get_releases(&self) -> Result<Vec<Release>, Error> {
139        let uri = format!("{}/repos/{}/releases", self.github_api, self.repo);
140        debug!("Making GET request to {} to check for new releases.", uri);
141
142        let resp = self.get(&uri).await?;
143        debug!(
144            "Received HTTP {} from GitHub when requesting releases.",
145            resp.status()
146        );
147
148        match resp.status() {
149            StatusCode::OK => {
150                let releases: Vec<GitHubRelease> = resp.json().await.wrap_system_err(
151                    "Unable to parse the response from the GitHub releases API.",
152                    &["The GitHub API may be unavailable or may have changed in an incompatible way; please report this issue if it persists."],
153                )?;
154
155                debug!("Received {} releases from GitHub.", releases.len());
156                Ok(self.get_releases_from_response(releases))
157            }
158            StatusCode::NOT_FOUND => Err(human_errors::user(
159                "GitHub returned a 404 Not Found when listing the releases for this repository.",
160                &[
161                    "Check that the repository exists and is public, and that the update manager is configured with the correct 'owner/name' repository identifier.",
162                ],
163            )),
164            StatusCode::TOO_MANY_REQUESTS | StatusCode::FORBIDDEN => Err(human_errors::user(
165                "GitHub has rate limited requests from your IP address.",
166                &["Please wait until GitHub removes this rate limit before trying again."],
167            )),
168            status => {
169                let body = resp.text().await.unwrap_or_default();
170                Err(human_errors::wrap_system(
171                    body,
172                    format!(
173                        "Received an HTTP {status} response from GitHub when listing the available releases."
174                    ),
175                    &[
176                        "Please read the error message below and decide if there is something you can do to fix the problem, or report the issue.",
177                    ],
178                ))
179            }
180        }
181    }
182
183    async fn get_binary<W: Write + Send>(
184        &self,
185        release: &Release,
186        variant: &ReleaseVariant,
187        into: &mut W,
188    ) -> Result<(), Error> {
189        let uri = format!(
190            "{}/{}/releases/download/{}/{}",
191            self.github_endpoint, self.repo, release.id, variant.name
192        );
193
194        self.download_to_file(&uri, variant.sha256.as_deref(), into)
195            .await
196    }
197}
198
199impl GitHubSource {
200    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
201    async fn get(&self, uri: &str) -> Result<reqwest::Response, Error> {
202        // The `User-Agent` is configured on the client (see `default_client` and
203        // `with_client`), so a caller-supplied client controls it and the crate
204        // never forces a second one onto the request.
205        self.client.get(uri).send().await.wrap_system_err(
206            format!("Failed to make a request to '{uri}'."),
207            &["Check your network connection and try again, or report the issue if it persists."],
208        )
209    }
210
211    fn get_releases_from_response(&self, releases: Vec<GitHubRelease>) -> Vec<Release> {
212        let mut output: Vec<Release> = Vec::with_capacity(releases.len());
213
214        for r in releases {
215            if !r.tag_name.starts_with(&self.release_tag_prefix) {
216                continue;
217            }
218
219            match r.tag_name[self.release_tag_prefix.len()..].parse() {
220                Ok(version) => {
221                    debug!("Found release '{}'.", r.tag_name);
222                    output.push(Release {
223                        id: r.tag_name.clone(),
224                        changelog: r.body.clone(),
225                        version,
226                        prerelease: r.prerelease,
227                        variant: self.get_variant_from_response(&r),
228                    })
229                }
230                Err(_) => {
231                    debug!(
232                        "Skipping release '{}' because it is not a valid SemVer version (adjust the release tag prefix to fix this).",
233                        &r.tag_name
234                    );
235                }
236            }
237        }
238
239        output
240    }
241
242    /// Select the first release asset whose name matches the configured glob
243    /// pattern, capturing its SHA-256 digest if GitHub reported one.
244    fn get_variant_from_response(&self, release: &GitHubRelease) -> Option<ReleaseVariant> {
245        release
246            .assets
247            .iter()
248            .find(|a| glob::matches(&self.asset_pattern, &a.name))
249            .map(|a| ReleaseVariant {
250                name: a.name.clone(),
251                sha256: a.digest.as_deref().and_then(parse_sha256_digest),
252            })
253    }
254
255    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, into)))]
256    async fn download_to_file<W: Write + Send>(
257        &self,
258        uri: &str,
259        expected_sha256: Option<&str>,
260        into: &mut W,
261    ) -> Result<(), Error> {
262        let resp = self.get(uri).await?;
263
264        match resp.status() {
265            StatusCode::OK => {
266                let mut hasher = Sha256::new();
267                let mut stream = resp.bytes_stream();
268
269                while let Some(chunk) = stream.next().await {
270                    let chunk = chunk.wrap_user_err(
271                        format!("Failed to download the update from '{uri}'."),
272                        &["Check your network connection and try again, or report the issue if it persists."],
273                    )?;
274                    hasher.update(&chunk);
275                    into.write_all(&chunk).wrap_user_err(
276                        format!("Could not write data downloaded from '{uri}' to disk due to an OS-level error."),
277                        &["Check that this tool has permission to create and write to this file and that the parent directory exists."],
278                    )?;
279                }
280
281                match expected_sha256 {
282                    Some(expected) => {
283                        let actual = to_hex(hasher.finalize().as_slice());
284                        if actual.eq_ignore_ascii_case(expected) {
285                            debug!("Verified the downloaded update against its SHA-256 digest.");
286                        } else {
287                            return Err(human_errors::user(
288                                format!(
289                                    "The update downloaded from '{uri}' failed its integrity check (expected SHA-256 {expected}, got {actual})."
290                                ),
291                                &[
292                                    "The download may have been corrupted in transit or tampered with. Please try the update again, and report the issue if it keeps happening.",
293                                ],
294                            ));
295                        }
296                    }
297                    None => {
298                        debug!(
299                            "No SHA-256 digest was reported for this asset; skipping the integrity check."
300                        );
301                    }
302                }
303
304                Ok(())
305            }
306            StatusCode::NOT_FOUND => Err(human_errors::user(
307                format!("GitHub returned a 404 Not Found when downloading '{uri}'."),
308                &[
309                    "This release variant may not be available for your platform, or the release may have been removed.",
310                ],
311            )),
312            StatusCode::TOO_MANY_REQUESTS | StatusCode::FORBIDDEN => Err(human_errors::user(
313                "GitHub has rate limited requests from your IP address.",
314                &["Please wait until GitHub removes this rate limit before trying again."],
315            )),
316            status => {
317                let body = resp.text().await.unwrap_or_default();
318                Err(human_errors::wrap_system(
319                    body,
320                    format!(
321                        "Received an HTTP {status} response from GitHub when downloading the update ({uri})."
322                    ),
323                    &[
324                        "Please read the error message below and decide if there is something you can do to fix the problem, or report the issue.",
325                    ],
326                ))
327            }
328        }
329    }
330}
331
332/// Parse a GitHub asset `digest` field (e.g. `"sha256:abc..."`) into its
333/// lowercase hex SHA-256, returning `None` for absent or unsupported algorithms.
334fn parse_sha256_digest(digest: &str) -> Option<String> {
335    digest
336        .strip_prefix("sha256:")
337        .map(|hex| hex.trim().to_ascii_lowercase())
338}
339
340/// Encode bytes as lowercase hexadecimal.
341fn to_hex(bytes: &[u8]) -> String {
342    use std::fmt::Write;
343    let mut s = String::with_capacity(bytes.len() * 2);
344    for b in bytes {
345        let _ = write!(s, "{b:02x}");
346    }
347    s
348}
349
350#[derive(Debug, Deserialize)]
351struct GitHubRelease {
352    #[allow(dead_code)]
353    pub name: String,
354    pub tag_name: String,
355    pub body: String,
356    pub prerelease: bool,
357    pub assets: Vec<GitHubAsset>,
358}
359
360#[derive(Debug, Deserialize)]
361struct GitHubAsset {
362    pub name: String,
363    #[serde(default)]
364    pub digest: Option<String>,
365}
366
367#[cfg(test)]
368mod tests {
369    use super::*;
370    use std::sync::{Arc, Mutex};
371    use wiremock::matchers::{method, path};
372    use wiremock::{Mock, MockServer, ResponseTemplate};
373
374    const RELEASES_JSON: &str = r#"[
375        {
376            "name": "Version 2.0.0",
377            "tag_name": "v2.0.0",
378            "body": "Example Release",
379            "prerelease": false,
380            "assets": [
381                { "name": "update-windows-amd64.exe" },
382                { "name": "update-linux-amd64" },
383                { "name": "update-darwin-amd64" }
384            ]
385        }
386    ]"#;
387
388    fn source_for(server: &MockServer, pattern: &str) -> GitHubSource {
389        GitHubSource::new("sierrasoftworks/update-rs", pattern)
390            .with_github_endpoints(&server.uri(), &server.uri())
391            .with_release_tag_prefix("v")
392    }
393
394    fn sha256_hex(data: &[u8]) -> String {
395        to_hex(Sha256::digest(data).as_slice())
396    }
397
398    fn releases_json(asset: &str, digest: Option<&str>) -> String {
399        let digest_field = match digest {
400            Some(d) => format!(r#", "digest": "{d}""#),
401            None => String::new(),
402        };
403        format!(
404            r#"[{{"name":"Version 2.0.0","tag_name":"v2.0.0","body":"Example Release","prerelease":false,"assets":[{{"name":"{asset}"{digest_field}}}]}}]"#
405        )
406    }
407
408    #[tokio::test]
409    async fn default_client_sends_the_crate_user_agent() {
410        let server = MockServer::start().await;
411        // The mock only matches when the request carries the crate's default
412        // User-Agent, so get_releases failing would mean it was missing.
413        Mock::given(method("GET"))
414            .and(path("/repos/sierrasoftworks/update-rs/releases"))
415            .and(wiremock::matchers::header("user-agent", USER_AGENT))
416            .respond_with(ResponseTemplate::new(200).set_body_string(RELEASES_JSON))
417            .mount(&server)
418            .await;
419
420        let source = source_for(&server, "update-linux-amd64");
421        let releases = source
422            .get_releases()
423            .await
424            .expect("the request should carry the crate's default User-Agent");
425        assert_eq!(releases.len(), 1);
426    }
427
428    #[tokio::test]
429    async fn with_client_uses_the_callers_user_agent() {
430        let server = MockServer::start().await;
431        // Only matches the caller's User-Agent, proving the crate uses the
432        // supplied client's UA and doesn't force its own onto it.
433        Mock::given(method("GET"))
434            .and(path("/repos/sierrasoftworks/update-rs/releases"))
435            .and(wiremock::matchers::header("user-agent", "my-app/1.2.3"))
436            .respond_with(ResponseTemplate::new(200).set_body_string(RELEASES_JSON))
437            .mount(&server)
438            .await;
439
440        let client = reqwest::Client::builder()
441            .user_agent("my-app/1.2.3")
442            .build()
443            .unwrap();
444        let source = source_for(&server, "update-linux-amd64").with_client(client);
445        let releases = source
446            .get_releases()
447            .await
448            .expect("the request should carry the caller's User-Agent");
449        assert_eq!(releases.len(), 1);
450    }
451
452    #[tokio::test]
453    async fn test_get_releases_selects_matching_asset() {
454        let server = MockServer::start().await;
455        Mock::given(method("GET"))
456            .and(path("/repos/sierrasoftworks/update-rs/releases"))
457            .respond_with(ResponseTemplate::new(200).set_body_string(RELEASES_JSON))
458            .mount(&server)
459            .await;
460
461        let source = source_for(&server, "update-linux-amd64");
462        let releases = source.get_releases().await.unwrap();
463
464        assert_eq!(releases.len(), 1);
465        let release = &releases[0];
466        assert_eq!(release.id, "v2.0.0");
467        assert_eq!(release.version.to_string(), "2.0.0");
468        assert!(!release.prerelease);
469        assert_ne!(release.changelog, "");
470
471        // Only the matching asset becomes the variant.
472        assert!(release.get_variant().is_some());
473        let variant = release.get_variant().unwrap();
474        assert_eq!(variant.name, "update-linux-amd64");
475        // No digest in this fixture.
476        assert!(variant.sha256.is_none());
477    }
478
479    #[tokio::test]
480    async fn test_get_releases_glob_pattern() {
481        let server = MockServer::start().await;
482        Mock::given(method("GET"))
483            .and(path("/repos/sierrasoftworks/update-rs/releases"))
484            .respond_with(ResponseTemplate::new(200).set_body_string(RELEASES_JSON))
485            .mount(&server)
486            .await;
487
488        // A glob that matches a single asset regardless of host platform.
489        let source = source_for(&server, "*-windows-amd64.exe");
490        let releases = source.get_releases().await.unwrap();
491
492        assert_eq!(
493            releases[0].get_variant().unwrap().name,
494            "update-windows-amd64.exe"
495        );
496    }
497
498    #[tokio::test]
499    async fn test_get_releases_no_match() {
500        let server = MockServer::start().await;
501        Mock::given(method("GET"))
502            .and(path("/repos/sierrasoftworks/update-rs/releases"))
503            .respond_with(ResponseTemplate::new(200).set_body_string(RELEASES_JSON))
504            .mount(&server)
505            .await;
506
507        let source = source_for(&server, "update-freebsd-amd64");
508        let releases = source.get_releases().await.unwrap();
509
510        assert_eq!(releases.len(), 1);
511        assert!(releases[0].get_variant().is_none());
512    }
513
514    #[tokio::test]
515    async fn test_get_releases_ignores_sidecar_files() {
516        let server = MockServer::start().await;
517        let body = r#"[{
518            "name": "Version 2.0.0",
519            "tag_name": "v2.0.0",
520            "body": "Example Release",
521            "prerelease": false,
522            "assets": [
523                { "name": "update-linux-amd64.sha256" },
524                { "name": "update-linux-amd64.sig" },
525                { "name": "update-linux-amd64" }
526            ]
527        }]"#;
528        Mock::given(method("GET"))
529            .and(path("/repos/sierrasoftworks/update-rs/releases"))
530            .respond_with(ResponseTemplate::new(200).set_body_string(body))
531            .mount(&server)
532            .await;
533
534        // An exact pattern must select the binary, not its checksum/signature
535        // sidecars, even when they are listed first.
536        let source = source_for(&server, "update-linux-amd64");
537        let releases = source.get_releases().await.unwrap();
538
539        assert_eq!(
540            releases[0].get_variant().unwrap().name,
541            "update-linux-amd64"
542        );
543    }
544
545    #[tokio::test]
546    async fn test_download() {
547        let server = MockServer::start().await;
548        Mock::given(method("GET"))
549            .and(path("/repos/sierrasoftworks/update-rs/releases"))
550            .respond_with(ResponseTemplate::new(200).set_body_string(RELEASES_JSON))
551            .mount(&server)
552            .await;
553        Mock::given(method("GET"))
554            .and(path(
555                "/sierrasoftworks/update-rs/releases/download/v2.0.0/update-linux-amd64",
556            ))
557            .respond_with(ResponseTemplate::new(200).set_body_string("example update content"))
558            .mount(&server)
559            .await;
560
561        let source = source_for(&server, "update-linux-amd64");
562        let releases = source.get_releases().await.unwrap();
563        let latest = Release::get_latest(releases.iter()).unwrap();
564        let variant = latest.get_variant().unwrap();
565
566        let mut target = Sink::new();
567        source
568            .get_binary(latest, variant, &mut target)
569            .await
570            .unwrap();
571
572        assert!(target.len() > 0);
573    }
574
575    #[tokio::test]
576    async fn test_download_verifies_matching_sha256() {
577        let body = "example update content";
578        let server = MockServer::start().await;
579        Mock::given(method("GET"))
580            .and(path("/repos/sierrasoftworks/update-rs/releases"))
581            .respond_with(ResponseTemplate::new(200).set_body_string(releases_json(
582                "update-linux-amd64",
583                Some(&format!("sha256:{}", sha256_hex(body.as_bytes()))),
584            )))
585            .mount(&server)
586            .await;
587        Mock::given(method("GET"))
588            .and(path(
589                "/sierrasoftworks/update-rs/releases/download/v2.0.0/update-linux-amd64",
590            ))
591            .respond_with(ResponseTemplate::new(200).set_body_string(body))
592            .mount(&server)
593            .await;
594
595        let source = source_for(&server, "update-linux-amd64");
596        let releases = source.get_releases().await.unwrap();
597        let latest = Release::get_latest(releases.iter()).unwrap();
598        let variant = latest.get_variant().unwrap();
599        assert!(
600            variant.sha256.is_some(),
601            "the digest should have been parsed from the API response"
602        );
603
604        let mut target = Sink::new();
605        source
606            .get_binary(latest, variant, &mut target)
607            .await
608            .expect("a matching digest should pass verification");
609        assert!(target.len() > 0);
610    }
611
612    #[tokio::test]
613    async fn test_download_rejects_bad_sha256() {
614        let server = MockServer::start().await;
615        Mock::given(method("GET"))
616            .and(path("/repos/sierrasoftworks/update-rs/releases"))
617            .respond_with(ResponseTemplate::new(200).set_body_string(releases_json(
618                "update-linux-amd64",
619                Some(&format!("sha256:{}", "0".repeat(64))),
620            )))
621            .mount(&server)
622            .await;
623        Mock::given(method("GET"))
624            .and(path(
625                "/sierrasoftworks/update-rs/releases/download/v2.0.0/update-linux-amd64",
626            ))
627            .respond_with(ResponseTemplate::new(200).set_body_string("the actual bytes"))
628            .mount(&server)
629            .await;
630
631        let source = source_for(&server, "update-linux-amd64");
632        let releases = source.get_releases().await.unwrap();
633        let latest = Release::get_latest(releases.iter()).unwrap();
634        let variant = latest.get_variant().unwrap();
635
636        let mut target = Sink::new();
637        let err = source
638            .get_binary(latest, variant, &mut target)
639            .await
640            .expect_err("a mismatched digest must fail the update");
641        assert!(
642            err.to_string().contains("integrity check"),
643            "unexpected error: {err}"
644        );
645    }
646
647    #[test]
648    fn test_parse_sha256_digest() {
649        assert_eq!(parse_sha256_digest("sha256:ABCdef"), Some("abcdef".into()));
650        assert_eq!(parse_sha256_digest("sha512:abc"), None);
651        assert_eq!(parse_sha256_digest(""), None);
652    }
653
654    struct Sink {
655        length: Arc<Mutex<usize>>,
656    }
657
658    impl Sink {
659        fn new() -> Self {
660            Self {
661                length: Arc::new(Mutex::new(0)),
662            }
663        }
664
665        fn len(&self) -> usize {
666            *self.length.lock().unwrap()
667        }
668    }
669
670    impl Write for Sink {
671        fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
672            *self.length.lock().unwrap() += buf.len();
673            Ok(buf.len())
674        }
675
676        fn flush(&mut self) -> std::io::Result<()> {
677            Ok(())
678        }
679    }
680}