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
15const USER_AGENT: &str = concat!("SierraSoftworks/update-rs v", env!("CARGO_PKG_VERSION"));
19
20fn default_client() -> reqwest::Client {
24 reqwest::Client::builder()
25 .user_agent(USER_AGENT)
26 .build()
27 .unwrap_or_else(|_| reqwest::Client::new())
28}
29
30pub 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 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 pub fn with_client(mut self, client: reqwest::Client) -> Self {
103 self.client = client;
104 self
105 }
106
107 pub fn with_release_tag_prefix(mut self, prefix: &str) -> Self {
110 self.release_tag_prefix = prefix.to_string();
111 self
112 }
113
114 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 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 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
332fn parse_sha256_digest(digest: &str) -> Option<String> {
335 digest
336 .strip_prefix("sha256:")
337 .map(|hex| hex.trim().to_ascii_lowercase())
338}
339
340fn 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 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 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 assert!(release.get_variant().is_some());
473 let variant = release.get_variant().unwrap();
474 assert_eq!(variant.name, "update-linux-amd64");
475 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 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 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}