Skip to main content

vs_core/service/
upgrade.rs

1use std::fs;
2use std::io::Cursor;
3use std::path::{Path, PathBuf};
4
5use flate2::read::GzDecoder;
6use reqwest::blocking::Client;
7use serde::Deserialize;
8use tar::Archive;
9use tempfile::Builder;
10use zip::ZipArchive;
11
12use crate::{App, CoreError, SelfUpgradeSummary};
13
14const RELEASE_REPOSITORY: &str = "unsdk/vs";
15
16#[derive(Debug, Deserialize)]
17struct LatestRelease {
18    tag_name: String,
19}
20
21impl App {
22    /// Upgrades the running `vs` binary to the latest published release.
23    pub fn upgrade_self(&self) -> Result<SelfUpgradeSummary, CoreError> {
24        let current_version = format!("v{}", env!("CARGO_PKG_VERSION"));
25        let latest_version = fetch_latest_release_tag()?;
26        if current_version == latest_version {
27            return Ok(SelfUpgradeSummary {
28                current_version,
29                latest_version,
30                updated: false,
31            });
32        }
33
34        let executable = std::env::current_exe()?;
35        let executable_dir = executable.parent().ok_or_else(|| {
36            CoreError::Unsupported(String::from("failed to resolve executable directory"))
37        })?;
38        let temp_dir = Builder::new()
39            .prefix("vs-upgrade-")
40            .tempdir_in(executable_dir)?;
41        let archive_name = release_archive_name(&latest_version);
42        let archive_path = temp_dir.path().join(&archive_name);
43        let download_url = release_asset_url(&latest_version);
44
45        let bytes = download_release_bytes(&download_url)?;
46        fs::write(&archive_path, bytes)?;
47
48        let replacement = if cfg!(windows) {
49            extract_zip_binary(&archive_path, temp_dir.path())?
50        } else {
51            extract_tar_gz_binary(&archive_path, temp_dir.path())?
52        };
53
54        replace_running_executable(&executable, &replacement)?;
55
56        Ok(SelfUpgradeSummary {
57            current_version,
58            latest_version,
59            updated: true,
60        })
61    }
62}
63
64fn fetch_latest_release_tag() -> Result<String, CoreError> {
65    let client = Client::builder()
66        .user_agent(format!("vs/{}", env!("CARGO_PKG_VERSION")))
67        .build()?;
68    let response = client
69        .get(format!(
70            "https://api.github.com/repos/{RELEASE_REPOSITORY}/releases/latest"
71        ))
72        .send()?
73        .error_for_status()?;
74    let release = response.json::<LatestRelease>()?;
75    Ok(release.tag_name)
76}
77
78fn download_release_bytes(url: &str) -> Result<Vec<u8>, CoreError> {
79    let client = Client::builder()
80        .user_agent(format!("vs/{}", env!("CARGO_PKG_VERSION")))
81        .build()?;
82    let response = client.get(url).send()?.error_for_status()?;
83    response
84        .bytes()
85        .map(|bytes| bytes.to_vec())
86        .map_err(Into::into)
87}
88
89fn release_asset_url(tag: &str) -> String {
90    format!(
91        "https://github.com/{RELEASE_REPOSITORY}/releases/download/{tag}/{}",
92        release_archive_name(tag)
93    )
94}
95
96fn release_archive_name(tag: &str) -> String {
97    format!(
98        "vs-{tag}-{}-{}.{}",
99        release_platform_label(),
100        release_feature_label(),
101        release_archive_extension()
102    )
103}
104
105fn release_platform_label() -> &'static str {
106    match std::env::consts::OS {
107        "macos" => "macos",
108        "windows" => "windows",
109        _ => "linux",
110    }
111}
112
113fn release_archive_extension() -> &'static str {
114    if cfg!(windows) { "zip" } else { "tar.gz" }
115}
116
117fn release_feature_label() -> &'static str {
118    #[cfg(all(feature = "lua", feature = "wasi"))]
119    {
120        "lua-wasi"
121    }
122    #[cfg(all(feature = "lua", not(feature = "wasi")))]
123    {
124        "lua"
125    }
126    #[cfg(all(feature = "wasi", not(feature = "lua")))]
127    {
128        "wasi"
129    }
130    #[cfg(not(any(feature = "lua", feature = "wasi")))]
131    {
132        "bare"
133    }
134}
135
136fn extract_tar_gz_binary(archive_path: &Path, destination: &Path) -> Result<PathBuf, CoreError> {
137    let bytes = fs::read(archive_path)?;
138    let cursor = Cursor::new(bytes);
139    let decoder = GzDecoder::new(cursor);
140    let mut archive = Archive::new(decoder);
141    archive.unpack(destination)?;
142    let binary_name = executable_name();
143    let extracted = destination.join(binary_name);
144    if extracted.exists() {
145        return Ok(extracted);
146    }
147    Err(CoreError::Unsupported(format!(
148        "failed to find extracted binary {}",
149        extracted.display()
150    )))
151}
152
153fn extract_zip_binary(archive_path: &Path, destination: &Path) -> Result<PathBuf, CoreError> {
154    let bytes = fs::read(archive_path)?;
155    let cursor = Cursor::new(bytes);
156    let mut archive = ZipArchive::new(cursor)?;
157    let binary_name = executable_name();
158
159    for index in 0..archive.len() {
160        let mut file = archive.by_index(index)?;
161        let Some(relative_path) = file.enclosed_name() else {
162            continue;
163        };
164        let output_path = destination.join(relative_path);
165        if file.name().ends_with('/') {
166            fs::create_dir_all(&output_path)?;
167            continue;
168        }
169        if let Some(parent) = output_path.parent() {
170            fs::create_dir_all(parent)?;
171        }
172        let mut output = fs::File::create(&output_path)?;
173        std::io::copy(&mut file, &mut output)?;
174        if output_path
175            .file_name()
176            .is_some_and(|name| name == binary_name)
177        {
178            return Ok(output_path);
179        }
180    }
181
182    Err(CoreError::Unsupported(format!(
183        "failed to find extracted binary {binary_name}"
184    )))
185}
186
187fn replace_running_executable(executable: &Path, replacement: &Path) -> Result<(), CoreError> {
188    #[cfg(windows)]
189    {
190        let backup = executable.with_extension("old.exe");
191        if backup.exists() {
192            fs::remove_file(&backup)?;
193        }
194        fs::rename(executable, &backup)?;
195        fs::rename(replacement, executable)?;
196
197        let cleanup_script = executable.with_extension("cleanup.bat");
198        let script = format!(
199            ":Repeat\r\ndel \"{}\"\r\nif exist \"{}\" goto Repeat\r\ndel \"{}\"\r\n",
200            backup.display(),
201            backup.display(),
202            cleanup_script.display()
203        );
204        fs::write(&cleanup_script, script)?;
205        std::process::Command::new("cmd.exe")
206            .args(["/C", cleanup_script.to_string_lossy().as_ref()])
207            .spawn()
208            .map_err(|error| CoreError::CommandExecution {
209                command: String::from("cmd.exe"),
210                message: error.to_string(),
211            })?;
212        Ok(())
213    }
214
215    #[cfg(not(windows))]
216    {
217        fs::rename(replacement, executable)?;
218        let mut permissions = fs::metadata(executable)?.permissions();
219        #[cfg(unix)]
220        {
221            use std::os::unix::fs::PermissionsExt;
222            permissions.set_mode(0o755);
223        }
224        fs::set_permissions(executable, permissions)?;
225        Ok(())
226    }
227}
228
229fn executable_name() -> &'static str {
230    if cfg!(windows) { "vs.exe" } else { "vs" }
231}
232
233#[cfg(test)]
234mod tests {
235    use super::{release_archive_name, release_asset_url, release_feature_label};
236
237    #[test]
238    fn release_asset_url_should_reference_current_repository() {
239        let url = release_asset_url("v1.2.3");
240        assert!(url.contains("github.com/unsdk/vs/releases/download/v1.2.3/"));
241        assert!(url.contains("v1.2.3"));
242    }
243
244    #[test]
245    fn release_archive_name_should_include_feature_variant() {
246        let archive_name = release_archive_name("v1.2.3");
247        assert!(archive_name.contains(release_feature_label()));
248    }
249}