Skip to main content

exeora_cli/
upgrade.rs

1use crate::CLI_VERSION;
2use anyhow::{Context, Result, anyhow, bail};
3use semver::Version;
4use serde_json::json;
5use sha2::{Digest, Sha256};
6use std::{env, fs};
7use url::Url;
8use uuid::Uuid;
9
10const RELEASES: &str = "https://github.com/leynier/exeora/releases";
11
12pub async fn run(json_output: bool) -> Result<()> {
13    let client = reqwest::Client::builder()
14        .user_agent(format!("exeora/{CLI_VERSION}"))
15        .build()?;
16    let release = client
17        .get(format!("{RELEASES}/latest"))
18        .send()
19        .await?
20        .error_for_status()?;
21    let (tag, latest) = release_from_url(release.url())?;
22    let current = Version::parse(CLI_VERSION).context("The compiled CLI version is invalid")?;
23
24    if latest <= current {
25        if json_output {
26            println!(
27                "{}",
28                json!({
29                    "updated": false,
30                    "currentVersion": current.to_string(),
31                    "latestVersion": latest.to_string(),
32                })
33            );
34        } else {
35            println!("Exeora {current} is already up to date.");
36        }
37        return Ok(());
38    }
39
40    let asset = asset_name()?;
41    let base = format!("{RELEASES}/download/{tag}");
42    let asset_url = format!("{base}/{asset}");
43    let checksums_url = format!("{base}/checksums-sha256.txt");
44    let (binary, checksums) = tokio::try_join!(
45        download(&client, &asset_url),
46        download(&client, &checksums_url),
47    )?;
48    verify_checksum(asset, &binary, &checksums)?;
49
50    let suffix = if cfg!(windows) { ".exe" } else { "" };
51    let temporary = env::temp_dir().join(format!(
52        "exeora-upgrade-{}{}",
53        Uuid::new_v4().simple(),
54        suffix
55    ));
56    fs::write(&temporary, binary).context("Could not stage the new Exeora executable")?;
57    let replacement = self_replace::self_replace(&temporary);
58    let _ = fs::remove_file(&temporary);
59    replacement.context("Could not replace the current Exeora executable")?;
60
61    if json_output {
62        println!(
63            "{}",
64            json!({
65                "updated": true,
66                "previousVersion": current.to_string(),
67                "version": latest.to_string(),
68            })
69        );
70    } else {
71        println!("Exeora was upgraded from {current} to {latest}.");
72    }
73    Ok(())
74}
75
76async fn download(client: &reqwest::Client, url: &str) -> Result<Vec<u8>> {
77    Ok(client
78        .get(url)
79        .send()
80        .await?
81        .error_for_status()?
82        .bytes()
83        .await?
84        .to_vec())
85}
86
87fn release_from_url(url: &Url) -> Result<(String, Version)> {
88    let tag = url
89        .path_segments()
90        .and_then(Iterator::last)
91        .filter(|value| !value.is_empty())
92        .ok_or_else(|| anyhow!("GitHub did not resolve the latest Exeora release"))?;
93    let raw_version = tag
94        .strip_prefix("cli-v")
95        .ok_or_else(|| anyhow!("Unexpected Exeora release tag: {tag}"))?;
96    let version = Version::parse(raw_version)
97        .with_context(|| format!("Unexpected Exeora release tag: {tag}"))?;
98    Ok((tag.to_owned(), version))
99}
100
101fn verify_checksum(asset: &str, binary: &[u8], checksums: &[u8]) -> Result<()> {
102    let checksums = std::str::from_utf8(checksums).context("The checksum file is not UTF-8")?;
103    let expected = checksums.lines().find_map(|line| {
104        let mut fields = line.split_whitespace();
105        let digest = fields.next()?;
106        let filename = fields.next()?.trim_start_matches('*');
107        (filename == asset).then_some(digest)
108    });
109    let Some(expected) = expected else {
110        bail!("The release has no checksum for {asset}.");
111    };
112    let actual = format!("{:x}", Sha256::digest(binary));
113    if !actual.eq_ignore_ascii_case(expected) {
114        bail!("Exeora checksum verification failed.");
115    }
116    Ok(())
117}
118
119fn asset_name() -> Result<&'static str> {
120    match (env::consts::OS, env::consts::ARCH) {
121        ("linux", "x86_64") => Ok("exeora-x86_64-unknown-linux-gnu"),
122        ("linux", "aarch64") => Ok("exeora-aarch64-unknown-linux-gnu"),
123        ("macos", "x86_64") => Ok("exeora-x86_64-apple-darwin"),
124        ("macos", "aarch64") => Ok("exeora-aarch64-apple-darwin"),
125        ("windows", "x86_64") => Ok("exeora-x86_64-pc-windows-msvc.exe"),
126        (os, architecture) => bail!("Unsupported platform: {os} {architecture}"),
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133
134    #[test]
135    fn parses_the_cli_release_tag() {
136        let url = Url::parse("https://github.com/leynier/exeora/releases/tag/cli-v1.2.3").unwrap();
137        let (tag, version) = release_from_url(&url).unwrap();
138        assert_eq!(tag, "cli-v1.2.3");
139        assert_eq!(version, Version::new(1, 2, 3));
140    }
141
142    #[test]
143    fn verifies_the_matching_asset_only() {
144        let binary = b"native-exeora";
145        let digest = format!("{:x}", Sha256::digest(binary));
146        let checksums = format!("deadbeef  another-asset\n{digest}  exeora-test\n");
147        verify_checksum("exeora-test", binary, checksums.as_bytes()).unwrap();
148        assert!(verify_checksum("missing", binary, checksums.as_bytes()).is_err());
149    }
150
151    #[test]
152    fn rejects_a_mismatched_checksum() {
153        assert!(verify_checksum("exeora-test", b"changed", b"deadbeef  exeora-test\n").is_err());
154    }
155}