Skip to main content

zoi_core/
upgrade.rs

1use std::env;
2use std::fs::File;
3use std::io::{self, Read, Write};
4use std::path::{Path, PathBuf};
5
6use anyhow::{Result, anyhow};
7use colored::Colorize;
8use dirs;
9use hex;
10use indicatif::{ProgressBar, ProgressStyle};
11use self_replace;
12use semver::Version;
13use serde::Deserialize;
14use sha2::{Digest, Sha512};
15use tar::Archive;
16use tempfile::Builder;
17use zip::ZipArchive;
18use zstd::stream::read::Decoder as ZstdDecoder;
19
20/// The GitLab project path for the Zoi repository.
21const GITLAB_PROJECT_PATH: &str = "zillowe/zillwen/zusty/zoi";
22/// The GitLab project ID for the Zoi repository.
23const GITLAB_PROJECT_ID: &str = "71087662";
24
25/// Represents a release from the GitLab API.
26#[derive(Debug, Deserialize)]
27struct GitLabRelease {
28    /// The tag name of the release.
29    tag_name: String
30}
31
32/// Fetches the latest tag from GitLab for a given branch prefix.
33fn get_latest_tag(branch_prefix: &str) -> Result<String> {
34    println!("Fetching latest release information from GitLab...");
35    let api_url = format!(
36        "https://gitlab.com/api/v4/projects/{GITLAB_PROJECT_ID}/releases"
37    );
38    let client = reqwest::blocking::Client::builder()
39        .user_agent("Zoi-Upgrader")
40        .use_rustls_tls()
41        .build()?;
42    let releases: Vec<GitLabRelease> = client.get(&api_url).send()?.json()?;
43
44    let latest_tag = releases
45        .into_iter()
46        .find(|r| r.tag_name.starts_with(branch_prefix))
47        .map(|r| r.tag_name)
48        .ok_or_else(|| {
49            anyhow!("No release found with prefix '{branch_prefix}'")
50        })?;
51
52    println!(
53        "Found latest tag for branch prefix '{}': {}",
54        branch_prefix,
55        latest_tag.green()
56    );
57    Ok(latest_tag)
58}
59
60/// Downloads a file from a URL to a local path with a progress bar.
61fn download_file(url: &str, path: &Path) -> Result<()> {
62    let mut response = reqwest::blocking::get(url)?;
63    if !response.status().is_success() {
64        return Err(anyhow!(
65            "Failed to download file: HTTP {}",
66            response.status()
67        ));
68    }
69
70    let total_size = response.content_length().unwrap_or(0);
71    let pb = ProgressBar::new(total_size);
72    pb.set_style(
73        ProgressStyle::default_bar()
74            .template(
75                "{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] \
76                 {bytes}/{total_bytes} ({bytes_per_sec})"
77            )?
78            .progress_chars("#>- ")
79    );
80
81    let mut dest = File::create(path)?;
82    let mut buffer = [0; 8192];
83
84    loop {
85        let bytes_read = response.read(&mut buffer)?;
86        if bytes_read == 0 {
87            break;
88        }
89        dest.write_all(
90            buffer
91                .get(..bytes_read)
92                .ok_or_else(|| anyhow!("Buffer overflow during write"))?
93        )?;
94        pb.inc(bytes_read as u64);
95    }
96
97    pb.finish_with_message("Download complete.");
98    Ok(())
99}
100
101/// Extracts a zip or zstd-compressed tar archive to a target directory.
102fn extract_archive(archive_path: &Path, target_dir: &Path) -> Result<()> {
103    println!("Extracting binary...");
104    let file = File::open(archive_path)?;
105
106    if archive_path.extension().and_then(|s| s.to_str()) == Some("zip") {
107        let mut archive = ZipArchive::new(file)?;
108        archive.extract(target_dir)?;
109    } else {
110        let tar = ZstdDecoder::new(file)?;
111        let mut archive = Archive::new(tar);
112        archive.unpack(target_dir)?;
113    }
114    Ok(())
115}
116
117/// Verifies the SHA-512 checksum of a file against expected content.
118fn verify_checksum(
119    file_path: &Path,
120    checksums_content: &str,
121    filename: &str
122) -> Result<()> {
123    println!("Verifying checksum for {filename}...");
124    let expected_hash = checksums_content
125        .lines()
126        .find(|line| line.contains(filename))
127        .and_then(|line| line.split_whitespace().next())
128        .ok_or(anyhow!("Checksum not found for {filename}."))?;
129
130    let mut file = File::open(file_path)?;
131    let mut hasher = Sha512::new();
132    let mut buffer = [0; 8192];
133    loop {
134        let bytes_read = io::Read::read(&mut file, &mut buffer)?;
135        if bytes_read == 0 {
136            break;
137        }
138        hasher.update(
139            buffer
140                .get(..bytes_read)
141                .ok_or_else(|| anyhow!("Buffer overflow during hash update"))?
142        );
143    }
144    let actual_hash = hex::encode(hasher.finalize());
145
146    if actual_hash != expected_hash {
147        return Err(anyhow!(
148            "Checksum mismatch for {filename}! The file may be corrupt."
149        ));
150    }
151    println!("Checksum verified successfully for {}.", filename.green());
152    Ok(())
153}
154
155/// Returns the current platform's OS and architecture labels used in release
156/// filenames.
157fn get_platform_info() -> Result<(&'static str, &'static str)> {
158    let os = match env::consts::OS {
159        "linux" => "linux",
160        "macos" | "darwin" => "macos",
161        "windows" => "windows",
162        _ => return Err(anyhow!("Unsupported OS: {}", env::consts::OS))
163    };
164    let arch = match env::consts::ARCH {
165        "x86_64" => "amd64",
166        "aarch64" => "arm64",
167        _ => {
168            return Err(anyhow!(
169                "Unsupported architecture: {}",
170                env::consts::ARCH
171            ));
172        }
173    };
174    Ok((os, arch))
175}
176
177/// Performs a full upgrade by downloading the entire binary archive.
178fn fallback_full_upgrade(
179    base_url: &str,
180    checksums_content: &str,
181    os: &str,
182    arch: &str
183) -> Result<(PathBuf, tempfile::TempDir)> {
184    let archive_ext = if os == "windows" { "zip" } else { "tar.zst" };
185    let archive_filename = format!("zoi-{os}-{arch}.{archive_ext}");
186    let download_url = format!("{base_url}/{archive_filename}");
187    let temp_dir = Builder::new().prefix("zoi-full-upgrade").tempdir()?;
188    let temp_archive_path = temp_dir.path().join(&archive_filename);
189
190    println!("Downloading Zoi from: {download_url}");
191    download_file(&download_url, &temp_archive_path)?;
192    verify_checksum(&temp_archive_path, checksums_content, &archive_filename)?;
193
194    extract_archive(&temp_archive_path, temp_dir.path())?;
195
196    let binary_filename = if os == "windows" { "zoi.exe" } else { "zoi" };
197    let new_binary_path = temp_dir.path().join(binary_filename);
198    if !new_binary_path.exists() {
199        return Err(anyhow!(
200            "Could not find executable in the extracted archive."
201        ));
202    }
203    Ok((new_binary_path, temp_dir))
204}
205
206/// Attempts a delta upgrade by downloading a bsdiff patch.
207fn try_delta_upgrade(
208    base_url: &str,
209    checksums_content: &str,
210    os: &str,
211    arch: &str,
212    current_version: &str,
213    latest_version: &str
214) -> Result<(PathBuf, tempfile::TempDir)> {
215    let archive_basename = format!("zoi-{os}-{arch}");
216    let bsdiff_filename = format!(
217        "{archive_basename}.from-v{current_version}-to-v{latest_version}.\
218         bsdiff"
219    );
220    let download_url = format!("{base_url}/{bsdiff_filename}");
221
222    if !checksums_content.contains(&bsdiff_filename) {
223        return Err(anyhow!(
224            "Delta patch not available for this upgrade path."
225        ));
226    }
227
228    let temp_dir = Builder::new().prefix("zoi-delta-upgrade").tempdir()?;
229    let temp_patch_path = temp_dir.path().join(&bsdiff_filename);
230
231    println!("{} Downloading delta patch...", "::".bold().blue());
232    download_file(&download_url, &temp_patch_path)?;
233    verify_checksum(&temp_patch_path, checksums_content, &bsdiff_filename)?;
234
235    println!("{} Applying delta patch...", "::".bold().blue());
236    let current_exe_path = env::current_exe()?;
237    let mut old_binary = Vec::new();
238    File::open(&current_exe_path)?.read_to_end(&mut old_binary)?;
239
240    let mut patch_data = Vec::new();
241    File::open(&temp_patch_path)?.read_to_end(&mut patch_data)?;
242
243    let raw_patch = if patch_data.starts_with(&[0x28, 0xB5, 0x2F, 0xFD]) {
244        let mut decoder = ZstdDecoder::new(std::io::Cursor::new(&patch_data))?;
245        let mut buf = Vec::new();
246        decoder.read_to_end(&mut buf)?;
247        buf
248    } else {
249        patch_data
250    };
251
252    let mut new_binary = Vec::new();
253    zbsdiff::Bspatch::new(&raw_patch)?
254        .apply(&old_binary, std::io::Cursor::new(&mut new_binary))?;
255
256    let binary_filename = if os == "windows" { "zoi.exe" } else { "zoi" };
257    let new_binary_path = temp_dir.path().join(binary_filename);
258    std::fs::write(&new_binary_path, &new_binary)?;
259
260    Ok((new_binary_path, temp_dir))
261}
262
263/// Runs the upgrade process for Zoi.
264///
265/// # Errors
266///
267/// Returns an error if:
268/// - Zoi is in offline mode.
269/// - The GitLab API cannot be reached.
270/// - The download fails or the checksum is invalid.
271/// - The binary replacement fails.
272pub fn run(
273    branch: &str,
274    status: &str,
275    number: &str,
276    force: bool,
277    tag: Option<String>,
278    custom_branch: Option<String>
279) -> Result<()> {
280    if crate::offline::is_offline() {
281        return Err(anyhow!("Cannot upgrade Zoi: Zoi is in offline mode."));
282    }
283    let current_exe_path = env::current_exe()?;
284    let path_str = current_exe_path.to_string_lossy();
285
286    let is_cargo_install = dirs::home_dir().is_some_and(|home| {
287        current_exe_path.starts_with(home.join(".cargo").join("bin"))
288    });
289
290    let pkg_manager = if path_str.contains("/Cellar/") {
291        Some("Homebrew")
292    } else if path_str.contains("scoop/apps/") {
293        Some("Scoop")
294    } else if path_str.starts_with("/usr/bin/") {
295        Some("a system package manager")
296    } else if is_cargo_install {
297        Some("Cargo")
298    } else {
299        None
300    };
301
302    if let Some(pm) = pkg_manager {
303        if !force {
304            eprintln!(
305                "{}{}{}",
306                "Warning: ".yellow().bold(),
307                "It looks like Zoi was installed via ".yellow(),
308                pm.yellow().bold()
309            );
310            eprintln!(
311                "{}",
312                "Using 'zoi upgrade' may conflict with your package manager."
313                    .yellow()
314            );
315            let upgrade_command = match pm {
316                "Homebrew" => "brew upgrade zoi",
317                "Scoop" => "scoop update zoi",
318                "Cargo" => "cargo install zoi-rs",
319                _ => "your package manager's upgrade command"
320            };
321            eprintln!(
322                "It is recommended to use '{}' to upgrade Zoi.",
323                upgrade_command.cyan()
324            );
325            eprintln!(
326                "To override this check and proceed anyway, run with the '{}' \
327                 flag.",
328                "--force".cyan()
329            );
330            return Err(anyhow!("managed_by_package_manager"));
331        }
332
333        println!(
334            "{}{}",
335            "Warning: ".yellow().bold(),
336            "Forcing self-upgrade on a package-manager-controlled \
337             installation."
338                .yellow()
339        );
340    }
341
342    let current_version = if status.is_empty()
343        || status.eq_ignore_ascii_case("stable")
344        || status.eq_ignore_ascii_case("release")
345    {
346        number.to_string()
347    } else {
348        format!("{}-{}", number, status.to_lowercase())
349    };
350
351    let latest_tag = if let Some(tag_name) = tag {
352        println!("Upgrading to specified tag: {}", tag_name.green());
353        tag_name
354    } else {
355        let branch_prefix = if let Some(b) = custom_branch {
356            println!("Upgrading to latest release from branch: {}", b.green());
357            format!("{b}-")
358        } else if branch.eq_ignore_ascii_case("public") {
359            "Pub-".to_string()
360        } else {
361            "Prod-".to_string()
362        };
363        get_latest_tag(&branch_prefix)?
364    };
365
366    let parts: Vec<&str> = latest_tag.split('-').collect();
367    let latest_version_num = if parts.len() >= 3 {
368        parts
369            .get(2)
370            .copied()
371            .ok_or_else(|| anyhow!("Missing version number in tag parts"))?
372    } else {
373        parts
374            .last()
375            .ok_or(anyhow!("Could not get version number from tag"))?
376    };
377
378    let latest_version_str = if parts.len() >= 3 {
379        let prerelease = parts
380            .get(1)
381            .copied()
382            .ok_or_else(|| anyhow!("Missing prerelease label in tag parts"))?
383            .to_lowercase();
384        if prerelease == "release" || prerelease == "stable" {
385            latest_version_num.to_string()
386        } else {
387            format!("{latest_version_num}-{prerelease}")
388        }
389    } else {
390        latest_version_num.to_string()
391    };
392
393    if !force
394        && (Version::parse(&latest_version_str)?
395            <= Version::parse(&current_version)?)
396    {
397        println!(
398            "
399{}",
400            "You are already on the latest version!".green()
401        );
402        return Err(anyhow!("already_on_latest"));
403    }
404
405    let (os, arch) = get_platform_info()?;
406
407    let base_url = format!(
408        "https://gitlab.com/{GITLAB_PROJECT_PATH}/-/releases/{latest_tag}/downloads"
409    );
410    let checksums_txt_url = format!("{base_url}/checksums.txt");
411
412    println!("Downloading archive and checksums from: {checksums_txt_url}");
413    let checksums_txt_content =
414        reqwest::blocking::get(&checksums_txt_url)?.text()?;
415
416    let (new_binary_path, _temp_dir_guard) = if force {
417        fallback_full_upgrade(&base_url, &checksums_txt_content, os, arch)?
418    } else {
419        match try_delta_upgrade(
420            &base_url,
421            &checksums_txt_content,
422            os,
423            arch,
424            &current_version,
425            &latest_version_str
426        ) {
427            Ok(res) => res,
428            Err(e) => {
429                println!(
430                    "Delta upgrade failed: {e}. Falling back to full upgrade."
431                );
432                fallback_full_upgrade(
433                    &base_url,
434                    &checksums_txt_content,
435                    os,
436                    arch
437                )?
438            }
439        }
440    };
441
442    println!("Replacing current executable...");
443    self_replace::self_replace(&new_binary_path)?;
444    Ok(())
445}