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