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 "distribution": "native",
69 })
70 );
71 } else {
72 println!("Exeora was upgraded from {current} to {latest}.");
73 }
74 Ok(())
75}
76
77async fn download(client: &reqwest::Client, url: &str) -> Result<Vec<u8>> {
78 Ok(client
79 .get(url)
80 .send()
81 .await?
82 .error_for_status()?
83 .bytes()
84 .await?
85 .to_vec())
86}
87
88fn release_from_url(url: &Url) -> Result<(String, Version)> {
89 let tag = url
90 .path_segments()
91 .and_then(Iterator::last)
92 .filter(|value| !value.is_empty())
93 .ok_or_else(|| anyhow!("GitHub did not resolve the latest Exeora release"))?;
94 let raw_version = tag
95 .strip_prefix("cli-v")
96 .ok_or_else(|| anyhow!("Unexpected Exeora release tag: {tag}"))?;
97 let version = Version::parse(raw_version)
98 .with_context(|| format!("Unexpected Exeora release tag: {tag}"))?;
99 Ok((tag.to_owned(), version))
100}
101
102fn verify_checksum(asset: &str, binary: &[u8], checksums: &[u8]) -> Result<()> {
103 let checksums = std::str::from_utf8(checksums).context("The checksum file is not UTF-8")?;
104 let expected = checksums.lines().find_map(|line| {
105 let mut fields = line.split_whitespace();
106 let digest = fields.next()?;
107 let filename = fields.next()?.trim_start_matches('*');
108 (filename == asset).then_some(digest)
109 });
110 let Some(expected) = expected else {
111 bail!("The release has no checksum for {asset}.");
112 };
113 let actual = format!("{:x}", Sha256::digest(binary));
114 if !actual.eq_ignore_ascii_case(expected) {
115 bail!("Exeora checksum verification failed.");
116 }
117 Ok(())
118}
119
120fn asset_name() -> Result<&'static str> {
121 match (env::consts::OS, env::consts::ARCH) {
122 ("linux", "x86_64") => Ok("exeora-x86_64-unknown-linux-gnu"),
123 ("linux", "aarch64") => Ok("exeora-aarch64-unknown-linux-gnu"),
124 ("macos", "x86_64") => Ok("exeora-x86_64-apple-darwin"),
125 ("macos", "aarch64") => Ok("exeora-aarch64-apple-darwin"),
126 ("windows", "x86_64") => Ok("exeora-x86_64-pc-windows-msvc.exe"),
127 (os, architecture) => bail!("Unsupported platform: {os} {architecture}"),
128 }
129}
130
131#[cfg(test)]
132mod tests {
133 use super::*;
134
135 #[test]
136 fn parses_the_cli_release_tag() {
137 let url = Url::parse("https://github.com/leynier/exeora/releases/tag/cli-v1.2.3").unwrap();
138 let (tag, version) = release_from_url(&url).unwrap();
139 assert_eq!(tag, "cli-v1.2.3");
140 assert_eq!(version, Version::new(1, 2, 3));
141 }
142
143 #[test]
144 fn verifies_the_matching_asset_only() {
145 let binary = b"native-exeora";
146 let digest = format!("{:x}", Sha256::digest(binary));
147 let checksums = format!("deadbeef another-asset\n{digest} exeora-test\n");
148 verify_checksum("exeora-test", binary, checksums.as_bytes()).unwrap();
149 assert!(verify_checksum("missing", binary, checksums.as_bytes()).is_err());
150 }
151
152 #[test]
153 fn rejects_a_mismatched_checksum() {
154 assert!(verify_checksum("exeora-test", b"changed", b"deadbeef exeora-test\n").is_err());
155 }
156}