Skip to main content

vs_core/service/
upgrade.rs

1//! Services for discovering and applying self-upgrades.
2
3use std::fs;
4use std::io::{Cursor, Read};
5use std::path::{Path, PathBuf};
6
7use flate2::read::GzDecoder;
8use indicatif::{ProgressBar, ProgressStyle};
9use reqwest::blocking::Client;
10use serde::Deserialize;
11use tar::Archive;
12use tempfile::Builder;
13use zip::ZipArchive;
14
15use super::version::{release_archive_extension, release_feature_label, release_target_triple};
16use crate::{App, CoreError, SelfUpgradeSummary};
17
18const RELEASE_REPOSITORY: &str = "unsdk/vs";
19
20#[derive(Debug, Deserialize)]
21struct ReleaseMetadata {
22    tag_name: String,
23    #[serde(default)]
24    assets: Vec<ReleaseAsset>,
25}
26
27#[derive(Debug, Deserialize)]
28struct ReleaseAsset {
29    name: String,
30    browser_download_url: String,
31}
32
33impl App {
34    /// Returns the current and latest available self-upgrade versions.
35    pub fn self_upgrade_summary(&self) -> Result<SelfUpgradeSummary, CoreError> {
36        let current_version = self.version_info()?.current_version;
37        let latest_version = fetch_latest_release()?.tag_name;
38        Ok(SelfUpgradeSummary {
39            updated: current_version != latest_version,
40            current_version,
41            latest_version,
42        })
43    }
44
45    /// Upgrades the running `vs` binary to the provided published release version.
46    pub fn upgrade_self_to(&self, latest_version: &str) -> Result<SelfUpgradeSummary, CoreError> {
47        let current_version = self.version_info()?.current_version;
48        if current_version == latest_version {
49            return Ok(SelfUpgradeSummary {
50                current_version,
51                latest_version: latest_version.to_string(),
52                updated: false,
53            });
54        }
55
56        let executable = std::env::current_exe()?;
57        let executable_dir = executable.parent().ok_or_else(|| {
58            CoreError::Unsupported(String::from("failed to resolve executable directory"))
59        })?;
60        let temp_dir = Builder::new()
61            .prefix("vs-upgrade-")
62            .tempdir_in(executable_dir)?;
63        println!(
64            "Preparing upgrade workspace in {}...",
65            temp_dir.path().display()
66        );
67
68        let release = fetch_release_by_tag(latest_version)?;
69        let asset = select_release_asset(&release)?;
70        println!(
71            "Resolved release asset {} for target {} with feature {}.",
72            asset.name,
73            release_target_triple(),
74            release_feature_label()?
75        );
76
77        let archive_name = &asset.name;
78        let archive_path = temp_dir.path().join(archive_name);
79        println!(
80            "Downloading {} to {}...",
81            asset.browser_download_url,
82            archive_path.display()
83        );
84        download_release_archive(&asset.browser_download_url, &archive_path)?;
85
86        let unpack_dir = temp_dir.path().join("unpacked");
87        let replacement = if cfg!(windows) {
88            extract_zip_binary(&archive_path, &unpack_dir)?
89        } else {
90            extract_tar_gz_binary(&archive_path, &unpack_dir)?
91        };
92
93        replace_running_executable(&executable, &replacement)?;
94
95        Ok(SelfUpgradeSummary {
96            current_version,
97            latest_version: latest_version.to_string(),
98            updated: true,
99        })
100    }
101}
102
103fn fetch_latest_release() -> Result<ReleaseMetadata, CoreError> {
104    fetch_release_from_endpoint(&format!(
105        "https://api.github.com/repos/{RELEASE_REPOSITORY}/releases/latest"
106    ))
107}
108
109fn fetch_release_by_tag(tag: &str) -> Result<ReleaseMetadata, CoreError> {
110    fetch_release_from_endpoint(&format!(
111        "https://api.github.com/repos/{RELEASE_REPOSITORY}/releases/tags/{tag}"
112    ))
113}
114
115fn fetch_release_from_endpoint(url: &str) -> Result<ReleaseMetadata, CoreError> {
116    let response = github_client()?.get(url).send()?.error_for_status()?;
117    response.json::<ReleaseMetadata>().map_err(Into::into)
118}
119
120fn github_client() -> Result<Client, CoreError> {
121    Client::builder()
122        .user_agent(format!("vs/{}", env!("CARGO_PKG_VERSION")))
123        .build()
124        .map_err(Into::into)
125}
126
127fn select_release_asset(release: &ReleaseMetadata) -> Result<&ReleaseAsset, CoreError> {
128    let target = release_target_triple();
129    let feature = release_feature_label()?;
130    let expected_name = release_archive_name(&release.tag_name)?;
131    release.assets.iter().find(|asset| asset.name == expected_name).ok_or_else(|| {
132        let available = release
133            .assets
134            .iter()
135            .map(|asset| asset.name.as_str())
136            .collect::<Vec<_>>()
137            .join(", ");
138        CoreError::Unsupported(format!(
139            "no release asset matched target {} with feature {}. expected {}, available assets: {}",
140            target,
141            feature,
142            expected_name,
143            available
144        ))
145    })
146}
147
148fn download_release_archive(url: &str, archive_path: &Path) -> Result<(), CoreError> {
149    let mut response = github_client()?.get(url).send()?.error_for_status()?;
150    let progress_bar = create_download_progress_bar(response.content_length());
151    let mut output = fs::File::create(archive_path)?;
152    let mut buffer = [0_u8; 8192];
153
154    loop {
155        let read = response.read(&mut buffer)?;
156        if read == 0 {
157            break;
158        }
159        std::io::Write::write_all(&mut output, &buffer[..read])?;
160        progress_bar.inc(read as u64);
161    }
162
163    progress_bar.finish_and_clear();
164    Ok(())
165}
166
167fn create_download_progress_bar(total_size: Option<u64>) -> ProgressBar {
168    let progress_bar = match total_size {
169        Some(total_size) => ProgressBar::new(total_size),
170        None => ProgressBar::new_spinner(),
171    };
172    let style = ProgressStyle::with_template(
173        "Downloading... {wide_bar} {bytes}/{total_bytes} ({bytes_per_sec})",
174    )
175    .unwrap_or_else(|_| ProgressStyle::default_bar())
176    .progress_chars("=> ");
177    progress_bar.set_style(style);
178    progress_bar
179}
180
181fn release_archive_name(tag: &str) -> Result<String, CoreError> {
182    let target = release_target_triple();
183    let feature = release_feature_label()?;
184    Ok(format!(
185        "vs-{tag}-{target}-{feature}.{}",
186        release_archive_extension()
187    ))
188}
189
190fn extract_tar_gz_binary(archive_path: &Path, destination: &Path) -> Result<PathBuf, CoreError> {
191    println!(
192        "Unpacking {} to {}...",
193        archive_path.display(),
194        destination.display()
195    );
196    fs::create_dir_all(destination)?;
197    let bytes = fs::read(archive_path)?;
198    let cursor = Cursor::new(bytes);
199    let decoder = GzDecoder::new(cursor);
200    let mut archive = Archive::new(decoder);
201    archive.unpack(destination)?;
202    let extracted = destination.join(executable_name());
203    if extracted.exists() {
204        println!("Extracted binary to {}.", extracted.display());
205        return Ok(extracted);
206    }
207    Err(CoreError::Unsupported(format!(
208        "failed to find extracted binary {}",
209        extracted.display()
210    )))
211}
212
213fn extract_zip_binary(archive_path: &Path, destination: &Path) -> Result<PathBuf, CoreError> {
214    println!(
215        "Unpacking {} to {}...",
216        archive_path.display(),
217        destination.display()
218    );
219    fs::create_dir_all(destination)?;
220    let bytes = fs::read(archive_path)?;
221    let cursor = Cursor::new(bytes);
222    let mut archive = ZipArchive::new(cursor)?;
223    let binary_name = executable_name();
224
225    for index in 0..archive.len() {
226        let mut file = archive.by_index(index)?;
227        let Some(relative_path) = file.enclosed_name() else {
228            continue;
229        };
230        let output_path = destination.join(relative_path);
231        if file.name().ends_with('/') {
232            fs::create_dir_all(&output_path)?;
233            continue;
234        }
235        if let Some(parent) = output_path.parent() {
236            fs::create_dir_all(parent)?;
237        }
238        let mut output = fs::File::create(&output_path)?;
239        std::io::copy(&mut file, &mut output)?;
240        if output_path
241            .file_name()
242            .is_some_and(|name| name == binary_name)
243        {
244            println!("Extracted binary to {}.", output_path.display());
245            return Ok(output_path);
246        }
247    }
248
249    Err(CoreError::Unsupported(format!(
250        "failed to find extracted binary {binary_name}"
251    )))
252}
253
254fn replace_running_executable(executable: &Path, replacement: &Path) -> Result<(), CoreError> {
255    #[cfg(windows)]
256    {
257        let backup = executable.with_extension("old.exe");
258        if backup.exists() {
259            fs::remove_file(&backup)?;
260        }
261        println!("Moving {} to {}...", executable.display(), backup.display());
262        fs::rename(executable, &backup)?;
263        println!(
264            "Moving {} to {}...",
265            replacement.display(),
266            executable.display()
267        );
268        fs::rename(replacement, executable)?;
269
270        let cleanup_script = executable.with_extension("cleanup.bat");
271        let script = format!(
272            ":Repeat\r\ndel \"{}\"\r\nif exist \"{}\" goto Repeat\r\ndel \"{}\"\r\n",
273            backup.display(),
274            backup.display(),
275            cleanup_script.display()
276        );
277        println!("Writing cleanup script to {}...", cleanup_script.display());
278        fs::write(&cleanup_script, script)?;
279        println!("Starting cleanup helper process...");
280        std::process::Command::new("cmd.exe")
281            .args(["/C", cleanup_script.to_string_lossy().as_ref()])
282            .spawn()
283            .map_err(|error| CoreError::CommandExecution {
284                command: String::from("cmd.exe"),
285                message: error.to_string(),
286            })?;
287        Ok(())
288    }
289
290    #[cfg(not(windows))]
291    {
292        println!(
293            "Moving {} to {}...",
294            replacement.display(),
295            executable.display()
296        );
297        fs::rename(replacement, executable)?;
298        let mut permissions = fs::metadata(executable)?.permissions();
299        #[cfg(unix)]
300        {
301            use std::os::unix::fs::PermissionsExt;
302            permissions.set_mode(0o755);
303        }
304        println!("Updating file permissions for {}...", executable.display());
305        fs::set_permissions(executable, permissions)?;
306        Ok(())
307    }
308}
309
310fn executable_name() -> &'static str {
311    if cfg!(windows) { "vs.exe" } else { "vs" }
312}
313
314#[cfg(test)]
315mod tests {
316    use super::{
317        ReleaseAsset, ReleaseMetadata, release_archive_name, release_feature_label,
318        release_target_triple, select_release_asset,
319    };
320
321    #[test]
322    fn select_release_asset_should_match_expected_archive_name() {
323        let archive_name = match release_archive_name("v1.2.3") {
324            Ok(name) => name,
325            Err(error) => panic!("release archive name should resolve: {error}"),
326        };
327        let release = ReleaseMetadata {
328            tag_name: String::from("v1.2.3"),
329            assets: vec![
330                ReleaseAsset {
331                    name: String::from("vs-v1.2.3-other-target-full.tar.gz"),
332                    browser_download_url: String::from("https://example.com/other"),
333                },
334                ReleaseAsset {
335                    name: archive_name,
336                    browser_download_url: String::from("https://example.com/match"),
337                },
338            ],
339        };
340
341        let asset = match select_release_asset(&release) {
342            Ok(asset) => asset,
343            Err(error) => panic!("release asset should match: {error}"),
344        };
345        assert_eq!(asset.browser_download_url, "https://example.com/match");
346    }
347
348    #[test]
349    fn release_archive_name_should_include_feature_variant() {
350        let archive_name = match release_archive_name("v1.2.3") {
351            Ok(name) => name,
352            Err(error) => panic!("release archive name should resolve: {error}"),
353        };
354        let feature = match release_feature_label() {
355            Ok(feature) => feature,
356            Err(error) => panic!("feature label should resolve: {error}"),
357        };
358        assert!(archive_name.contains(feature));
359    }
360
361    #[test]
362    fn release_archive_name_should_include_target_triple() {
363        let archive_name = match release_archive_name("v1.2.3") {
364            Ok(name) => name,
365            Err(error) => panic!("release archive name should resolve: {error}"),
366        };
367        let target = release_target_triple();
368        assert!(archive_name.contains(target));
369    }
370}