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