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