Skip to main content

corundum_framework_update/
install.rs

1//! Replacing the running executable with a newer release.
2
3#[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/// The most a download may weigh. A release binary is tens of megabytes; this only stops a
22/// runaway response from filling memory.
23#[cfg(feature = "self-update")]
24const MAX_DOWNLOAD: u64 = 512 * 1024 * 1024;
25
26/// How long one download may take.
27#[cfg(feature = "self-update")]
28const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(15 * 60);
29
30/// The `update` subcommand's arguments: replace this executable with its newest release.
31#[cfg(feature = "cli")]
32#[derive(Clone, Debug, Default, clap::Args)]
33pub struct UpdateArgs {
34    /// Only say whether a newer release exists; download nothing.
35    #[arg(long)]
36    pub check: bool,
37    /// Install without asking first.
38    #[arg(long, short = 'y')]
39    pub yes: bool,
40}
41
42/// How the running executable came to be where it is, which decides whether `update` may
43/// replace it.
44#[derive(Clone, Debug, PartialEq, Eq)]
45pub enum InstallMethod {
46    /// Downloaded from a release and placed by hand: `update` replaces it.
47    Release,
48    /// `cargo install`, under `$CARGO_HOME/bin`: cargo's records would no longer match a
49    /// replaced file, so `update` says to run `cargo install` instead.
50    Cargo,
51    /// `cargo binstall`, under `$CARGO_HOME/bin` and recorded in
52    /// `$CARGO_HOME/binstall/crates-v1.json`: `update` says to run `cargo binstall` again.
53    Binstall {
54        /// The crate it was installed from.
55        crate_name: String,
56        /// Where that crate came from.
57        source: BinstallSource,
58    },
59    /// A system or package-manager location (`/usr/bin`, the Nix store, Homebrew): the
60    /// package manager updates it.
61    PackageManager,
62    /// A store's own package (Flatpak, Snap, MSIX, Mac App Store): the store updates it, and
63    /// replacing a file inside it would fail or be undone.
64    Store(StoreKind),
65}
66
67/// The store an app was installed from.
68#[derive(Clone, Copy, Debug, PartialEq, Eq)]
69pub enum StoreKind {
70    /// Flathub, or another Flatpak remote.
71    Flatpak,
72    /// The Snap Store.
73    Snap,
74    /// The Microsoft Store (an MSIX package).
75    Msix,
76    /// The Mac App Store.
77    MacAppStore,
78}
79
80/// What the running process says about the package it runs in: the facts that are not in the
81/// executable's path.
82#[derive(Clone, Debug)]
83pub(crate) struct Probe {
84    /// The file system root, where Flatpak puts `.flatpak-info`.
85    pub root: PathBuf,
86    /// Running as a snap (`$SNAP` is set).
87    pub snap: bool,
88    /// Running with MSIX package identity.
89    pub msix: bool,
90}
91
92impl Probe {
93    /// Ask this process.
94    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/// Whether the process has MSIX package identity.
104#[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    // SAFETY: a null buffer with a zero length only asks for the length.
110    let status = unsafe { GetCurrentPackageFullName(&mut len, None) };
111    // With a package the zero length is too small; without one the error is NO_PACKAGE.
112    status == ERROR_INSUFFICIENT_BUFFER
113}
114
115#[cfg(not(windows))]
116fn has_package_identity() -> bool {
117    false
118}
119
120/// Where `cargo binstall` took a crate from, which decides how to run it again.
121#[derive(Clone, Debug, PartialEq, Eq)]
122pub enum BinstallSource {
123    /// crates.io: `cargo binstall <crate>`.
124    Registry,
125    /// A git repository, by its URL without the commit: `cargo binstall --git <url> <crate>`.
126    /// The crates that are only released on GitHub, such as the desktop apps.
127    Git(String),
128    /// A local manifest, or a source this does not know.
129    Other,
130}
131
132/// How the executable at `exe` was installed, judged by where it is and, under
133/// `$CARGO_HOME/bin`, by `cargo binstall`'s records. `cargo_home` is `$CARGO_HOME`, or
134/// `~/.cargo` when unset.
135pub fn install_method(exe: &Path, cargo_home: Option<&Path>) -> InstallMethod {
136    install_method_with(exe, cargo_home, &Probe::detect())
137}
138
139/// [`install_method`] with what the process says about its package given as `probe`.
140pub(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    // `App.app/Contents/MacOS/<exe>`, with the App Store's receipt beside `MacOS`.
155    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    // `/usr/local` is where binaries placed by hand go; the rest of `/usr` is the
178    // distribution's.
179    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/// One crate in `$CARGO_HOME/binstall/crates-v1.json`, as far as this reads it.
198#[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
213/// How `cargo binstall` installed the binary `bin`, from `records`, the contents of
214/// `$CARGO_HOME/binstall/crates-v1.json`: one JSON object per crate, one after another. `None`
215/// when binstall did not install it, which leaves `cargo install`.
216///
217/// A binary binstall installed and `cargo install` replaced afterwards still reads as
218/// binstall's; running binstall again puts the release back, which is what `update` is for.
219fn 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
241/// This machine as release files name it: `linux-x86_64`, `macos-aarch64`,
242/// `windows-x86_64`.
243pub fn platform() -> String {
244    format!("{}-{}", std::env::consts::OS, std::env::consts::ARCH)
245}
246
247/// The file a release publishes `base` under for this machine:
248/// `<base>-<os>-<arch>`, with `.exe` on Windows.
249pub fn asset_name(base: &str) -> String {
250    format!("{base}-{}{}", platform(), std::env::consts::EXE_SUFFIX)
251}
252
253/// Write `bytes` to a new executable file `.update-*` in `dir`, flushed to disk. It is removed
254/// when the returned file is dropped.
255#[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/// Write `bytes` beside `exe` and hand the new file to `replace`, which puts it in `exe`'s
271/// place. The staging file is removed afterwards, whatever `replace` did.
272///
273/// The staging file is in `exe`'s directory so the replacement is a rename on one file
274/// system, never a half-written executable.
275#[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/// Download `url`, refusing anything larger than [`MAX_DOWNLOAD`].
293#[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/// Download `release`'s file for this machine, the one `source` names with
320/// [`ReleaseSource::asset`].
321///
322/// [`Error::NotConfigured`] when `source` names none, [`Error::NoAsset`] when the release has
323/// no file for this platform, and [`Error::Http`] when the download fails, is empty or is
324/// larger than [`MAX_DOWNLOAD`].
325#[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
356/// The command that updates a binary installed as `method`, when there is one to run:
357/// `cargo install` or `cargo binstall` with what it was installed from.
358pub 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
370/// What to tell the user to do about `release` when the binary installed as `method` is not
371/// replaced in place: where to download it, or which package manager to ask.
372pub 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
401/// `$CARGO_HOME`, or `~/.cargo`.
402pub 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"))]
409/// Carry out `update` for the binary `name` at `current`, whose releases `source` names;
410/// returns the exit code.
411///
412/// Prints what it finds and does. Exit 0 is up to date, installed, or (with `--check`) a
413/// newer release found; 1 is a newer release this binary does not install itself.
414pub 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"))]
477/// Ask `question` on the terminal; only `y` or `yes` is a yes.
478fn 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        /// Serve the routes `routes` builds from the server's own root (path → body) on a
718        /// loopback port until the test ends; returns the root.
719        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        /// A repository with release 0.20.0 of `app`: its file for this machine.
749        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}