1#[cfg(all(feature = "cli", feature = "self-update"))]
4use std::io::BufRead;
5#[cfg(feature = "self-update")]
6use std::io::Write as _;
7use std::path::{Path, PathBuf};
8#[cfg(feature = "self-update")]
9use std::time::Duration;
10
11use serde::Deserialize;
12
13#[cfg(feature = "self-update")]
14use crate::Error;
15#[cfg(feature = "self-update")]
16use crate::Result;
17#[cfg(all(feature = "cli", feature = "self-update"))]
18use crate::release::latest_release;
19use crate::release::{Release, ReleaseSource};
20
21#[cfg(feature = "self-update")]
24const MAX_DOWNLOAD: u64 = 512 * 1024 * 1024;
25
26#[cfg(feature = "self-update")]
28const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(15 * 60);
29
30#[cfg(feature = "cli")]
32#[derive(Clone, Debug, Default, clap::Args)]
33pub struct UpdateArgs {
34 #[arg(long)]
36 pub check: bool,
37 #[arg(long, short = 'y')]
39 pub yes: bool,
40}
41
42#[derive(Clone, Debug, PartialEq, Eq)]
45pub enum InstallMethod {
46 Release,
48 Cargo,
51 Binstall {
54 crate_name: String,
56 source: BinstallSource,
58 },
59 PackageManager,
62 Store(StoreKind),
65 Velopack,
68}
69
70#[derive(Clone, Copy, Debug, PartialEq, Eq)]
72pub enum StoreKind {
73 Flatpak,
75 Snap,
77 Msix,
79 MacAppStore,
81}
82
83#[derive(Clone, Debug)]
86pub(crate) struct Probe {
87 pub root: PathBuf,
89 pub snap: bool,
91 pub msix: bool,
93 pub appimage: Option<PathBuf>,
95}
96
97impl Probe {
98 pub(crate) fn detect() -> Self {
100 Self {
101 root: PathBuf::from("/"),
102 snap: std::env::var_os("SNAP").is_some_and(|v| !v.is_empty()),
103 msix: has_package_identity(),
104 appimage: std::env::var_os("APPIMAGE")
105 .filter(|v| !v.is_empty())
106 .map(PathBuf::from),
107 }
108 }
109}
110
111#[cfg(windows)]
113fn has_package_identity() -> bool {
114 use windows::Win32::Foundation::ERROR_INSUFFICIENT_BUFFER;
115 use windows::Win32::Storage::Packaging::Appx::GetCurrentPackageFullName;
116 let mut len = 0u32;
117 let status = unsafe { GetCurrentPackageFullName(&mut len, None) };
119 status == ERROR_INSUFFICIENT_BUFFER
121}
122
123#[cfg(not(windows))]
124fn has_package_identity() -> bool {
125 false
126}
127
128#[derive(Clone, Debug, PartialEq, Eq)]
130pub enum BinstallSource {
131 Registry,
133 Git(String),
136 Other,
138}
139
140pub fn install_method(exe: &Path, cargo_home: Option<&Path>) -> InstallMethod {
144 install_method_with(exe, cargo_home, &Probe::detect())
145}
146
147pub(crate) fn install_method_with(
149 exe: &Path,
150 cargo_home: Option<&Path>,
151 probe: &Probe,
152) -> InstallMethod {
153 if probe.root.join(".flatpak-info").exists() {
154 return InstallMethod::Store(StoreKind::Flatpak);
155 }
156 if probe.snap {
157 return InstallMethod::Store(StoreKind::Snap);
158 }
159 if probe.msix {
160 return InstallMethod::Store(StoreKind::Msix);
161 }
162 if let Some(macos) = exe.parent()
164 && let Some(contents) = macos.parent()
165 && macos.file_name().is_some_and(|d| d == "MacOS")
166 && contents.file_name().is_some_and(|d| d == "Contents")
167 && contents.join("_MASReceipt").join("receipt").exists()
168 {
169 return InstallMethod::Store(StoreKind::MacAppStore);
170 }
171 if corundum_framework_platform::package::current_with(exe, probe.appimage.as_deref()).is_some()
172 {
173 return InstallMethod::Velopack;
174 }
175 if let Some(home) = cargo_home
176 && exe.starts_with(home.join("bin"))
177 {
178 let records = std::fs::read_to_string(home.join("binstall").join("crates-v1.json"));
179 let bin = exe.file_name().and_then(|name| name.to_str()).map(|name| {
180 name.strip_suffix(std::env::consts::EXE_SUFFIX)
181 .unwrap_or(name)
182 });
183 return match (records, bin) {
184 (Ok(records), Some(bin)) => binstalled(&records, bin),
185 _ => None,
186 }
187 .unwrap_or(InstallMethod::Cargo);
188 }
189 if exe.starts_with("/usr/local") {
192 return InstallMethod::Release;
193 }
194 const MANAGED: [&str; 7] = [
195 "/usr",
196 "/bin",
197 "/sbin",
198 "/nix",
199 "/opt/homebrew",
200 "/home/linuxbrew",
201 "/snap",
202 ];
203 if MANAGED.iter().any(|dir| exe.starts_with(dir)) {
204 return InstallMethod::PackageManager;
205 }
206 InstallMethod::Release
207}
208
209#[derive(Deserialize)]
211struct BinstallRecord {
212 name: String,
213 #[serde(default)]
214 source: Option<BinstallRecordSource>,
215 #[serde(default)]
216 bins: Vec<String>,
217}
218
219#[derive(Deserialize)]
220struct BinstallRecordSource {
221 source_type: String,
222 url: String,
223}
224
225fn binstalled(records: &str, bin: &str) -> Option<InstallMethod> {
232 serde_json::Deserializer::from_str(records)
233 .into_iter::<BinstallRecord>()
234 .map_while(std::result::Result::ok)
235 .filter(|record| record.bins.iter().any(|b| b == bin))
236 .last()
237 .map(|record| InstallMethod::Binstall {
238 source: match record.source {
239 Some(source) if source.source_type == "Registry" => BinstallSource::Registry,
240 Some(source) if source.source_type == "Git" => {
241 let url = source
242 .url
243 .split_once('#')
244 .map_or(source.url.as_str(), |(u, _)| u);
245 BinstallSource::Git(url.to_owned())
246 }
247 _ => BinstallSource::Other,
248 },
249 crate_name: record.name,
250 })
251}
252
253pub fn platform() -> String {
256 format!("{}-{}", std::env::consts::OS, std::env::consts::ARCH)
257}
258
259pub fn asset_name(base: &str) -> String {
262 format!("{base}-{}{}", platform(), std::env::consts::EXE_SUFFIX)
263}
264
265#[cfg(feature = "self-update")]
268pub(crate) fn stage(dir: &Path, bytes: &[u8]) -> Result<tempfile::NamedTempFile> {
269 let mut staged = tempfile::Builder::new()
270 .prefix(".update-")
271 .tempfile_in(dir)?;
272 staged.write_all(bytes)?;
273 staged.as_file().sync_all()?;
274 #[cfg(unix)]
275 {
276 use std::os::unix::fs::PermissionsExt;
277 std::fs::set_permissions(staged.path(), std::fs::Permissions::from_mode(0o755))?;
278 }
279 Ok(staged)
280}
281
282#[cfg(feature = "self-update")]
288pub fn install(
289 bytes: &[u8],
290 exe: &Path,
291 replace: impl FnOnce(&Path) -> std::io::Result<()>,
292) -> Result<()> {
293 let dir = exe.parent().ok_or_else(|| {
294 Error::Io(std::io::Error::other(format!(
295 "{} has no directory",
296 exe.display()
297 )))
298 })?;
299 let staged = stage(dir, bytes)?;
300 replace(staged.path())?;
301 Ok(())
302}
303
304#[cfg(feature = "self-update")]
306async fn download(client: &reqwest::Client, url: &str) -> Result<Vec<u8>> {
307 let response = client
308 .get(url)
309 .timeout(DOWNLOAD_TIMEOUT)
310 .send()
311 .await
312 .and_then(reqwest::Response::error_for_status)
313 .map_err(|e| Error::Http(format!("downloading {url}: {e}")))?;
314 if response.content_length().is_some_and(|n| n > MAX_DOWNLOAD) {
315 return Err(Error::Http(format!(
316 "{url} is larger than {MAX_DOWNLOAD} bytes"
317 )));
318 }
319 let bytes = response
320 .bytes()
321 .await
322 .map_err(|e| Error::Http(format!("downloading {url}: {e}")))?;
323 if bytes.len() as u64 > MAX_DOWNLOAD {
324 return Err(Error::Http(format!(
325 "{url} is larger than {MAX_DOWNLOAD} bytes"
326 )));
327 }
328 Ok(bytes.to_vec())
329}
330
331#[cfg(feature = "self-update")]
338pub async fn download_asset(
339 client: &reqwest::Client,
340 source: &ReleaseSource,
341 release: &Release,
342) -> Result<Vec<u8>> {
343 let base = source.asset_base().ok_or_else(|| {
344 Error::NotConfigured(format!(
345 "{} does not name a release file to download",
346 source.repository()
347 ))
348 })?;
349 let name = asset_name(base);
350 let binary = release
351 .assets
352 .iter()
353 .find(|a| a.name == name)
354 .ok_or_else(|| {
355 Error::NoAsset(format!(
356 "{} {} has no {name}",
357 source.repository(),
358 release.version
359 ))
360 })?;
361 let bytes = download(client, &binary.download_url).await?;
362 if bytes.is_empty() {
363 return Err(Error::Http(format!("{name} downloaded empty")));
364 }
365 Ok(bytes)
366}
367
368pub fn advice_command(source: &ReleaseSource, method: &InstallMethod) -> Option<String> {
371 match (method, source.crate_name()) {
372 (InstallMethod::Cargo, Some(name)) => Some(format!("cargo install {name} --locked")),
373 (InstallMethod::Binstall { crate_name, source }, _) => match source {
374 BinstallSource::Registry => Some(format!("cargo binstall {crate_name}")),
375 BinstallSource::Git(url) => Some(format!("cargo binstall --git {url} {crate_name}")),
376 BinstallSource::Other => None,
377 },
378 _ => None,
379 }
380}
381
382pub fn advice(source: &ReleaseSource, release: &Release, method: &InstallMethod) -> String {
385 if let Some(command) = advice_command(source, method) {
386 let tool = match method {
387 InstallMethod::Cargo => "cargo",
388 _ => "cargo binstall",
389 };
390 return format!("installed with {tool}; run: {command}");
391 }
392 match method {
393 InstallMethod::Cargo => "installed with cargo; update it with cargo install".to_owned(),
394 InstallMethod::Binstall { .. } => {
395 "installed with cargo binstall; update it with cargo binstall".to_owned()
396 }
397 InstallMethod::PackageManager => {
398 "installed by a package manager; update it with that".to_owned()
399 }
400 InstallMethod::Store(store) => format!(
401 "installed from {}; it updates there",
402 match store {
403 StoreKind::Flatpak => "Flathub",
404 StoreKind::Snap => "the Snap Store",
405 StoreKind::Msix => "the Microsoft Store",
406 StoreKind::MacAppStore => "the Mac App Store",
407 }
408 ),
409 InstallMethod::Velopack => "Updated by the app itself.".to_owned(),
410 InstallMethod::Release => format!("download it from {}", release.url),
411 }
412}
413
414pub fn cargo_home() -> Option<PathBuf> {
416 std::env::var_os("CARGO_HOME")
417 .map(PathBuf::from)
418 .or_else(|| std::env::home_dir().map(|home| home.join(".cargo")))
419}
420
421#[cfg(all(feature = "cli", feature = "self-update"))]
422pub async fn run(
428 args: &UpdateArgs,
429 source: &ReleaseSource,
430 name: &str,
431 current: &str,
432) -> Result<i32> {
433 let current_v = semver::Version::parse(current).map_err(|e| {
434 Error::NotConfigured(format!(
435 "this binary's version {current:?} is not semver: {e}"
436 ))
437 })?;
438 let client = crate::client(&format!("{name}/{current}"))?;
439 let release = latest_release(&client, source).await?;
440 if release.version <= current_v {
441 println!(
442 "{name} {current} is up to date (latest release {})",
443 release.version
444 );
445 return Ok(0);
446 }
447 println!(
448 "{name} {} is available (installed: {current}): {}",
449 release.version, release.url
450 );
451 if args.check {
452 return Ok(0);
453 }
454
455 if source.asset_base().is_none() {
456 println!(
457 "{name} does not update itself; {}",
458 advice(source, &release, &InstallMethod::Release)
459 );
460 return Ok(1);
461 }
462 let exe = std::env::current_exe()?;
463 let exe = exe.canonicalize().unwrap_or(exe);
464 let method = install_method(&exe, cargo_home().as_deref());
465 if method != InstallMethod::Release {
466 println!("{} is {}", exe.display(), advice(source, &release, &method));
467 return Ok(1);
468 }
469
470 if !args.yes
471 && !confirm(&format!(
472 "replace {} with {}?",
473 exe.display(),
474 release.version
475 ))?
476 {
477 println!("not updated");
478 return Ok(1);
479 }
480 let bytes = download_asset(&client, source, &release).await?;
481 install(&bytes, &exe, |staged| self_replace::self_replace(staged))?;
482 println!(
483 "installed {name} {}; restart the {name} service to run it",
484 release.version
485 );
486 Ok(0)
487}
488
489#[cfg(all(feature = "cli", feature = "self-update"))]
490fn confirm(question: &str) -> Result<bool> {
492 print!("{question} [y/N] ");
493 std::io::stdout().flush()?;
494 let mut answer = String::new();
495 std::io::stdin().lock().read_line(&mut answer)?;
496 Ok(matches!(
497 answer.trim().to_ascii_lowercase().as_str(),
498 "y" | "yes"
499 ))
500}
501
502#[cfg(test)]
503mod tests {
504 use super::*;
505
506 fn probe(root: &Path) -> Probe {
507 Probe {
508 root: root.to_owned(),
509 snap: false,
510 msix: false,
511 appimage: None,
512 }
513 }
514
515 #[test]
516 fn a_velopack_package_is_the_package_s_to_update() {
517 let tmp = tempfile::tempdir().unwrap();
518 let current = tmp.path().join("current");
519 std::fs::create_dir(¤t).unwrap();
520 std::fs::write(tmp.path().join("Update.exe"), "").unwrap();
521 let exe = current.join("app.exe");
522 let empty = tempfile::tempdir().unwrap();
523 let method = install_method_with(&exe, None, &probe(empty.path()));
524 assert_eq!(method, InstallMethod::Velopack);
525 let source = ReleaseSource::github("o", "r");
526 assert_eq!(advice_command(&source, &method), None);
527 }
528
529 #[test]
530 fn advice_carries_the_command_to_run() {
531 let source = ReleaseSource::github("o", "r").cargo_crate("app-crate");
532 let release = Release {
533 version: "1.0.0".parse().unwrap(),
534 url: "https://example.test/r".to_owned(),
535 assets: vec![],
536 };
537 let binstall = |source| InstallMethod::Binstall {
538 crate_name: "app-crate".to_owned(),
539 source,
540 };
541 let git = binstall(BinstallSource::Git("https://github.com/o/r".to_owned()));
542 let cases = [
543 (
544 InstallMethod::Cargo,
545 Some("cargo install app-crate --locked"),
546 "installed with cargo; run: cargo install app-crate --locked",
547 ),
548 (
549 binstall(BinstallSource::Registry),
550 Some("cargo binstall app-crate"),
551 "installed with cargo binstall; run: cargo binstall app-crate",
552 ),
553 (
554 git,
555 Some("cargo binstall --git https://github.com/o/r app-crate"),
556 "installed with cargo binstall; run: cargo binstall --git https://github.com/o/r app-crate",
557 ),
558 (
559 binstall(BinstallSource::Other),
560 None,
561 "installed with cargo binstall; update it with cargo binstall",
562 ),
563 (
564 InstallMethod::PackageManager,
565 None,
566 "installed by a package manager; update it with that",
567 ),
568 (
569 InstallMethod::Release,
570 None,
571 "download it from https://example.test/r",
572 ),
573 ];
574 for (method, command, words) in cases {
575 assert_eq!(advice_command(&source, &method).as_deref(), command);
576 assert_eq!(advice(&source, &release, &method), words);
577 }
578 let bare = ReleaseSource::github("o", "r");
579 assert_eq!(advice_command(&bare, &InstallMethod::Cargo), None);
580 assert_eq!(
581 advice(&bare, &release, &InstallMethod::Cargo),
582 "installed with cargo; update it with cargo install"
583 );
584 }
585
586 #[test]
587 fn a_binary_is_replaced_only_where_it_was_placed_by_hand() {
588 let home = Path::new("/home/u/.cargo");
589 let root = tempfile::tempdir().unwrap();
590 let method = |p: &str| install_method_with(Path::new(p), Some(home), &probe(root.path()));
591 assert_eq!(method("/home/u/.cargo/bin/app"), InstallMethod::Cargo);
592 assert_eq!(method("/usr/bin/app"), InstallMethod::PackageManager);
593 assert_eq!(
594 method("/nix/store/abc-app/bin/app"),
595 InstallMethod::PackageManager
596 );
597 assert_eq!(
598 method("/opt/homebrew/bin/app"),
599 InstallMethod::PackageManager
600 );
601 assert_eq!(method("/usr/local/bin/app"), InstallMethod::Release);
602 assert_eq!(method("/home/u/.local/bin/app"), InstallMethod::Release);
603 }
604
605 #[test]
606 fn an_app_in_a_store_package_is_the_store_s_to_update() {
607 let tmp = tempfile::tempdir().unwrap();
608 let exe = Path::new("/home/u/.local/bin/app");
609 let method = |p: &Probe| install_method_with(exe, None, p);
610 assert_eq!(method(&probe(tmp.path())), InstallMethod::Release);
611
612 std::fs::write(tmp.path().join(".flatpak-info"), "").unwrap();
613 assert_eq!(
614 method(&probe(tmp.path())),
615 InstallMethod::Store(StoreKind::Flatpak)
616 );
617
618 let empty = tempfile::tempdir().unwrap();
619 let snap = Probe {
620 snap: true,
621 ..probe(empty.path())
622 };
623 assert_eq!(method(&snap), InstallMethod::Store(StoreKind::Snap));
624 let msix = Probe {
625 msix: true,
626 ..probe(empty.path())
627 };
628 assert_eq!(method(&msix), InstallMethod::Store(StoreKind::Msix));
629 }
630
631 #[test]
632 fn a_mac_app_store_bundle_is_recognised_by_its_receipt() {
633 let tmp = tempfile::tempdir().unwrap();
634 let contents = tmp.path().join("App.app").join("Contents");
635 std::fs::create_dir_all(contents.join("MacOS")).unwrap();
636 let exe = contents.join("MacOS").join("app");
637 let p = probe(tmp.path());
638 assert_eq!(install_method_with(&exe, None, &p), InstallMethod::Release);
639 std::fs::create_dir(contents.join("_MASReceipt")).unwrap();
640 std::fs::write(contents.join("_MASReceipt").join("receipt"), "").unwrap();
641 assert_eq!(
642 install_method_with(&exe, None, &p),
643 InstallMethod::Store(StoreKind::MacAppStore)
644 );
645 }
646
647 #[test]
648 fn binstall_s_records_name_the_crate_and_where_it_came_from() {
649 let records = concat!(
650 r#"{"name":"dioxus-cli","version_req":"*","current_version":"0.7.3","#,
651 r#""source":{"source_type":"Registry","url":"https://github.com/rust-lang/crates.io-index"},"#,
652 r#""target":"x86_64-unknown-linux-gnu","bins":["dx"]}"#,
653 r#"{"name":"sapphire-journal-desktop","version_req":"*","current_version":"0.3.0","#,
654 r#""source":{"source_type":"Git","url":"https://github.com/sapphire-turtle/sapphire-journal#b05d5c2"},"#,
655 r#""target":"x86_64-unknown-linux-gnu","bins":["sapphire-journal-desktop"]}"#,
656 );
657 assert_eq!(
658 binstalled(records, "dx"),
659 Some(InstallMethod::Binstall {
660 crate_name: "dioxus-cli".to_owned(),
661 source: BinstallSource::Registry,
662 })
663 );
664 assert_eq!(
665 binstalled(records, "sapphire-journal-desktop"),
666 Some(InstallMethod::Binstall {
667 crate_name: "sapphire-journal-desktop".to_owned(),
668 source: BinstallSource::Git(
669 "https://github.com/sapphire-turtle/sapphire-journal".to_owned()
670 ),
671 })
672 );
673 assert_eq!(binstalled(records, "sapphire-journal"), None);
674 assert_eq!(binstalled("", "dx"), None);
675 }
676
677 #[test]
678 fn a_binary_under_cargo_s_bin_is_binstall_s_only_when_it_says_so() {
679 let tmp = tempfile::tempdir().unwrap();
680 let exe = tmp
681 .path()
682 .join("bin")
683 .join(format!("dx{}", std::env::consts::EXE_SUFFIX));
684 assert_eq!(
685 install_method_with(&exe, Some(tmp.path()), &probe(tmp.path())),
686 InstallMethod::Cargo
687 );
688 std::fs::create_dir(tmp.path().join("binstall")).unwrap();
689 std::fs::write(
690 tmp.path().join("binstall").join("crates-v1.json"),
691 r#"{"name":"dioxus-cli","source":{"source_type":"Registry","url":""},"bins":["dx"]}"#,
692 )
693 .unwrap();
694 assert_eq!(
695 install_method_with(&exe, Some(tmp.path()), &probe(tmp.path())),
696 InstallMethod::Binstall {
697 crate_name: "dioxus-cli".to_owned(),
698 source: BinstallSource::Registry,
699 }
700 );
701 }
702
703 #[test]
704 fn the_asset_names_this_machine() {
705 let name = asset_name("sapphire-bridge");
706 assert!(name.starts_with("sapphire-bridge-"));
707 assert!(name.contains(std::env::consts::OS));
708 assert!(name.contains(std::env::consts::ARCH));
709 }
710
711 #[test]
712 #[cfg(feature = "self-update")]
713 fn install_puts_the_bytes_in_place_and_leaves_no_staging_file() {
714 let tmp = tempfile::tempdir().unwrap();
715 let exe = tmp.path().join("app");
716 std::fs::write(&exe, b"old").unwrap();
717 install(b"new", &exe, |staged| {
718 std::fs::copy(staged, &exe).map(|_| ())
719 })
720 .unwrap();
721 assert_eq!(std::fs::read(&exe).unwrap(), b"new");
722 let left: Vec<_> = std::fs::read_dir(tmp.path()).unwrap().collect();
723 assert_eq!(left.len(), 1, "the staging file is gone");
724 #[cfg(unix)]
725 {
726 use std::os::unix::fs::PermissionsExt;
727 let staged_mode = std::fs::metadata(&exe).unwrap().permissions().mode();
728 assert_eq!(
729 staged_mode & 0o111,
730 0o111,
731 "the copy keeps the executable bit"
732 );
733 }
734 }
735
736 #[cfg(feature = "self-update")]
737 mod download {
738 use super::*;
739 use crate::latest_release;
740
741 fn v(s: &str) -> semver::Version {
742 semver::Version::parse(s).unwrap()
743 }
744
745 async fn serve(routes: impl FnOnce(&str) -> Vec<(String, Vec<u8>)>) -> String {
748 use tokio::io::{AsyncReadExt, AsyncWriteExt};
749 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
750 let root = format!("http://{}", listener.local_addr().unwrap());
751 let routes = routes(&root);
752 tokio::spawn(async move {
753 loop {
754 let (mut socket, _) = listener.accept().await.unwrap();
755 let mut buf = vec![0u8; 8192];
756 let n = socket.read(&mut buf).await.unwrap();
757 let request = String::from_utf8_lossy(&buf[..n]);
758 let path = request.split_whitespace().nth(1).unwrap_or("").to_owned();
759 let (status, body) = match routes.iter().find(|(p, _)| *p == path) {
760 Some((_, body)) => ("200 OK", body.clone()),
761 None => ("404 Not Found", Vec::new()),
762 };
763 let head = format!(
764 "HTTP/1.1 {status}\r\ncontent-length: {}\r\nconnection: close\r\n\r\n",
765 body.len()
766 );
767 socket.write_all(head.as_bytes()).await.unwrap();
768 socket.write_all(&body).await.unwrap();
769 }
770 });
771 root
772 }
773
774 const RELEASES: &str = "/repos/o/r/releases?per_page=100";
775
776 async fn repository(binary: &[u8]) -> String {
778 let name = asset_name("app");
779 let binary = binary.to_vec();
780 serve(move |root| {
781 let releases = format!(
782 r#"[{{"tag_name":"v0.20.0","html_url":"{root}/r/v0.20.0","assets":[
783 {{"name":"{name}","browser_download_url":"{root}/dl/{name}"}}]}}]"#
784 );
785 vec![
786 (RELEASES.to_owned(), releases.into_bytes()),
787 (format!("/dl/{name}"), binary),
788 ]
789 })
790 .await
791 }
792
793 #[tokio::test]
794 async fn a_release_is_fetched_end_to_end() {
795 let root = repository(b"new binary").await;
796 let source = ReleaseSource::github("o", "r").api_base(&root).asset("app");
797 let client = crate::client("test/0.0.0").unwrap();
798 let release = latest_release(&client, &source).await.unwrap();
799 assert_eq!(release.version, v("0.20.0"));
800 let bytes = download_asset(&client, &source, &release).await.unwrap();
801 assert_eq!(bytes, b"new binary");
802 }
803
804 #[tokio::test]
805 async fn an_empty_download_is_refused() {
806 let root = repository(b"").await;
807 let source = ReleaseSource::github("o", "r").api_base(&root).asset("app");
808 let client = crate::client("test/0.0.0").unwrap();
809 let release = latest_release(&client, &source).await.unwrap();
810 let err = download_asset(&client, &source, &release)
811 .await
812 .unwrap_err();
813 assert!(matches!(err, Error::Http(_)), "{err}");
814 }
815
816 #[tokio::test]
817 async fn a_release_without_this_platforms_file_is_refused() {
818 let root = serve(|_| {
819 vec![(
820 RELEASES.to_owned(),
821 br#"[{"tag_name":"v0.20.0","html_url":"https://example.invalid","assets":[]}]"#
822 .to_vec(),
823 )]
824 })
825 .await;
826 let source = ReleaseSource::github("o", "r").api_base(&root).asset("app");
827 let client = crate::client("test/0.0.0").unwrap();
828 let release = latest_release(&client, &source).await.unwrap();
829 let err = download_asset(&client, &source, &release)
830 .await
831 .unwrap_err();
832 assert!(matches!(err, Error::NoAsset(_)), "{err}");
833 }
834 }
835}