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    /// When the release was created, used to pick the newest match.
31    #[serde(default)]
32    created_at: String
33}
34
35/// Branch identifiers, as used in release tags and the `--branch` flag.
36///
37/// ZFVM defines the vocabulary in the `zfvm` crate. The list is duplicated
38/// here only so the error message can name the valid options without
39/// formatting a borrowed slice of the dependency.
40const BRANCH_NAMES: [&str; 4] = ["Prod", "Dev", "Spec", "Pub"];
41
42/// Projects a ZFVM version onto the `SemVer` string used for the bsdiff patch
43/// naming that `scripts/archive.sh` writes.
44///
45/// The status becomes a numeric pre-release ordinal and the build becomes
46/// build metadata, matching `scripts/archive.sh` and the `zfvm` crate. The
47/// branch does not participate: `archive.sh` builds these names from the tag
48/// alone, and any mismatch between the two sides silently disables delta
49/// upgrades in favour of full downloads.
50///
51/// # Errors
52///
53/// Returns an error when `number` is not a valid `SemVer` version, or when the
54/// projected string cannot be parsed back.
55fn zfvm_to_semver(number: &str, status: &str) -> Result<String> {
56    // - `stable` is not a ZFVM status. It is accepted here because tags
57    // - predating the ZFVM vocabulary may carry it, and rejecting it would
58    // - strand those installs on the full-download path.
59    let parsed_status = if status.eq_ignore_ascii_case("stable") {
60        zfvm::Status::Release
61    } else {
62        status
63            .parse::<zfvm::Status>()
64            .map_err(|e| anyhow!("Malformed ZFVM status '{status}': {e}"))?
65    };
66
67    let core = semver::Version::parse(number).map_err(|e| {
68        anyhow!("Malformed ZFVM version number '{number}': {e}")
69    })?;
70
71    let version =
72        zfvm::Version::new(zfvm::Branch::Prod, parsed_status, core, None)
73            .map_err(|e| anyhow!("Cannot build ZFVM version: {e}"))?;
74
75    // Round-trip through the parser so this function can never return a
76    // string the upgrade comparison would fail to parse.
77    let projected = version.to_semver();
78    semver::Version::parse(&projected).map_err(|e| {
79        anyhow!("ZFVM projection '{projected}' is not valid SemVer: {e}")
80    })?;
81
82    Ok(projected)
83}
84
85/// Fetches the latest tag from GitLab for a given branch prefix.
86fn get_latest_tag(branch_prefix: &str) -> Result<String> {
87    println!("Fetching latest release information from GitLab...");
88    let api_url = format!(
89        "https://gitlab.com/api/v4/projects/{GITLAB_PROJECT_ID}/releases"
90    );
91    let client = reqwest::blocking::Client::builder()
92        .user_agent("Zoi-Upgrader")
93        .use_rustls_tls()
94        .build()?;
95    let releases: Vec<GitLabRelease> = client.get(&api_url).send()?.json()?;
96
97    // - Releases are returned newest-first by the GitLab API, but the ordering
98    // - is not contractual. Sort by `created_at` so "latest" means latest even
99    // - if the API changes its default ordering.
100    let latest_tag = releases
101        .into_iter()
102        .filter(|r| r.tag_name.starts_with(branch_prefix))
103        .max_by(|a, b| a.created_at.cmp(&b.created_at))
104        .map(|r| r.tag_name)
105        .ok_or_else(|| {
106            anyhow!("No release found with prefix '{branch_prefix}'")
107        })?;
108
109    println!(
110        "Found latest tag for branch prefix '{}': {}",
111        branch_prefix,
112        latest_tag.green()
113    );
114    Ok(latest_tag)
115}
116
117/// Downloads a file from a URL to a local path with a progress bar.
118fn download_file(url: &str, path: &Path) -> Result<()> {
119    let mut response = reqwest::blocking::get(url)?;
120    if !response.status().is_success() {
121        return Err(anyhow!(
122            "Failed to download file: HTTP {}",
123            response.status()
124        ));
125    }
126
127    let total_size = response.content_length().unwrap_or(0);
128    let pb = ProgressBar::new(total_size);
129    pb.set_style(
130        ProgressStyle::default_bar()
131            .template(
132                "{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] \
133                 {bytes}/{total_bytes} ({bytes_per_sec})"
134            )?
135            .progress_chars("#>- ")
136    );
137
138    let mut dest = File::create(path)?;
139    let mut buffer = [0; 8192];
140
141    loop {
142        let bytes_read = response.read(&mut buffer)?;
143        if bytes_read == 0 {
144            break;
145        }
146        dest.write_all(
147            buffer
148                .get(..bytes_read)
149                .ok_or_else(|| anyhow!("Buffer overflow during write"))?
150        )?;
151        pb.inc(bytes_read as u64);
152    }
153
154    pb.finish_with_message("Download complete.");
155    Ok(())
156}
157
158/// Extracts a zip or zstd-compressed tar archive to a target directory.
159fn extract_archive(archive_path: &Path, target_dir: &Path) -> Result<()> {
160    println!("Extracting binary...");
161    let file = File::open(archive_path)?;
162
163    if archive_path.extension().and_then(|s| s.to_str()) == Some("zip") {
164        let mut archive = ZipArchive::new(file)?;
165        archive.extract(target_dir)?;
166    } else {
167        let tar = ZstdDecoder::new(file)?;
168        let mut archive = Archive::new(tar);
169        archive.unpack(target_dir)?;
170    }
171    Ok(())
172}
173
174/// Verifies the SHA-512 checksum of a file against expected content.
175fn verify_checksum(
176    file_path: &Path,
177    checksums_content: &str,
178    filename: &str
179) -> Result<()> {
180    println!("Verifying checksum for {filename}...");
181    let expected_hash = checksums_content
182        .lines()
183        .find(|line| line.contains(filename))
184        .and_then(|line| line.split_whitespace().next())
185        .ok_or(anyhow!("Checksum not found for {filename}."))?;
186
187    let mut file = File::open(file_path)?;
188    let mut hasher = Sha512::new();
189    let mut buffer = [0; 8192];
190    loop {
191        let bytes_read = io::Read::read(&mut file, &mut buffer)?;
192        if bytes_read == 0 {
193            break;
194        }
195        hasher.update(
196            buffer
197                .get(..bytes_read)
198                .ok_or_else(|| anyhow!("Buffer overflow during hash update"))?
199        );
200    }
201    let actual_hash = hex::encode(hasher.finalize());
202
203    if actual_hash != expected_hash {
204        return Err(anyhow!(
205            "Checksum mismatch for {filename}! The file may be corrupt."
206        ));
207    }
208    println!("Checksum verified successfully for {}.", filename.green());
209    Ok(())
210}
211
212/// Returns the current platform's OS and architecture labels used in release
213/// filenames.
214fn get_platform_info() -> Result<(&'static str, &'static str)> {
215    let os = match env::consts::OS {
216        "linux" => "linux",
217        "macos" | "darwin" => "macos",
218        "windows" => "windows",
219        _ => return Err(anyhow!("Unsupported OS: {}", env::consts::OS))
220    };
221    let arch = match env::consts::ARCH {
222        "x86_64" => "amd64",
223        "aarch64" => "arm64",
224        _ => {
225            return Err(anyhow!(
226                "Unsupported architecture: {}",
227                env::consts::ARCH
228            ));
229        }
230    };
231    Ok((os, arch))
232}
233
234/// Performs a full upgrade by downloading the entire binary archive.
235fn fallback_full_upgrade(
236    base_url: &str,
237    checksums_content: &str,
238    os: &str,
239    arch: &str
240) -> Result<(PathBuf, tempfile::TempDir)> {
241    let archive_ext = if os == "windows" { "zip" } else { "tar.zst" };
242    let archive_filename = format!("zoi-{os}-{arch}.{archive_ext}");
243    let download_url = format!("{base_url}/{archive_filename}");
244    let temp_dir = Builder::new().prefix("zoi-full-upgrade").tempdir()?;
245    let temp_archive_path = temp_dir.path().join(&archive_filename);
246
247    println!("Downloading Zoi from: {download_url}");
248    download_file(&download_url, &temp_archive_path)?;
249    verify_checksum(&temp_archive_path, checksums_content, &archive_filename)?;
250
251    extract_archive(&temp_archive_path, temp_dir.path())?;
252
253    let binary_filename = if os == "windows" { "zoi.exe" } else { "zoi" };
254    let new_binary_path = temp_dir.path().join(binary_filename);
255    if !new_binary_path.exists() {
256        return Err(anyhow!(
257            "Could not find executable in the extracted archive."
258        ));
259    }
260    Ok((new_binary_path, temp_dir))
261}
262
263/// Attempts a delta upgrade by downloading a bsdiff patch.
264fn try_delta_upgrade(
265    base_url: &str,
266    checksums_content: &str,
267    os: &str,
268    arch: &str,
269    current_version: &str,
270    latest_version: &str
271) -> Result<(PathBuf, tempfile::TempDir)> {
272    let archive_basename = format!("zoi-{os}-{arch}");
273    let bsdiff_filename = format!(
274        "{archive_basename}.from-v{current_version}-to-v{latest_version}.\
275         bsdiff"
276    );
277    let download_url = format!("{base_url}/{bsdiff_filename}");
278
279    if !checksums_content.contains(&bsdiff_filename) {
280        return Err(anyhow!(
281            "Delta patch not available for this upgrade path."
282        ));
283    }
284
285    let temp_dir = Builder::new().prefix("zoi-delta-upgrade").tempdir()?;
286    let temp_patch_path = temp_dir.path().join(&bsdiff_filename);
287
288    println!("{} Downloading delta patch...", "::".bold().blue());
289    download_file(&download_url, &temp_patch_path)?;
290    verify_checksum(&temp_patch_path, checksums_content, &bsdiff_filename)?;
291
292    println!("{} Applying delta patch...", "::".bold().blue());
293    let current_exe_path = env::current_exe()?;
294    let mut old_binary = Vec::new();
295    File::open(&current_exe_path)?.read_to_end(&mut old_binary)?;
296
297    let mut patch_data = Vec::new();
298    File::open(&temp_patch_path)?.read_to_end(&mut patch_data)?;
299
300    let raw_patch = if patch_data.starts_with(&[0x28, 0xB5, 0x2F, 0xFD]) {
301        let mut decoder = ZstdDecoder::new(std::io::Cursor::new(&patch_data))?;
302        let mut buf = Vec::new();
303        decoder.read_to_end(&mut buf)?;
304        buf
305    } else {
306        patch_data
307    };
308
309    let mut new_binary = Vec::new();
310    zbsdiff::Bspatch::new(&raw_patch)?
311        .apply(&old_binary, std::io::Cursor::new(&mut new_binary))?;
312
313    let binary_filename = if os == "windows" { "zoi.exe" } else { "zoi" };
314    let new_binary_path = temp_dir.path().join(binary_filename);
315    std::fs::write(&new_binary_path, &new_binary)?;
316
317    Ok((new_binary_path, temp_dir))
318}
319
320/// Runs the upgrade process for Zoi.
321///
322/// # Errors
323///
324/// Returns an error if:
325/// - Zoi is in offline mode.
326/// - The GitLab API cannot be reached.
327/// - The download fails or the checksum is invalid.
328/// - The binary replacement fails.
329pub fn run(
330    branch: &str,
331    status: &str,
332    number: &str,
333    force: bool,
334    tag: Option<String>,
335    custom_branch: Option<String>
336) -> Result<()> {
337    if crate::offline::is_offline() {
338        return Err(anyhow!("Cannot upgrade Zoi: Zoi is in offline mode."));
339    }
340    let current_exe_path = env::current_exe()?;
341    let path_str = current_exe_path.to_string_lossy();
342
343    let is_cargo_install = dirs::home_dir().is_some_and(|home| {
344        current_exe_path.starts_with(home.join(".cargo").join("bin"))
345    });
346
347    let pkg_manager = if path_str.contains("/Cellar/") {
348        Some("Homebrew")
349    } else if path_str.contains("scoop/apps/") {
350        Some("Scoop")
351    } else if path_str.starts_with("/usr/bin/") {
352        Some("a system package manager")
353    } else if is_cargo_install {
354        Some("Cargo")
355    } else {
356        None
357    };
358
359    if let Some(pm) = pkg_manager {
360        if !force {
361            eprintln!(
362                "{}{}{}",
363                "Warning: ".yellow().bold(),
364                "It looks like Zoi was installed via ".yellow(),
365                pm.yellow().bold()
366            );
367            eprintln!(
368                "{}",
369                "Using 'zoi upgrade' may conflict with your package manager."
370                    .yellow()
371            );
372            let upgrade_command = match pm {
373                "Homebrew" => "brew upgrade zoi",
374                "Scoop" => "scoop update zoi",
375                "Cargo" => "cargo install zoi-rs",
376                _ => "your package manager's upgrade command"
377            };
378            eprintln!(
379                "It is recommended to use '{}' to upgrade Zoi.",
380                upgrade_command.cyan()
381            );
382            eprintln!(
383                "To override this check and proceed anyway, run with the '{}' \
384                 flag.",
385                "--force".cyan()
386            );
387            return Err(anyhow!("managed_by_package_manager"));
388        }
389
390        println!(
391            "{}{}",
392            "Warning: ".yellow().bold(),
393            "Forcing self-upgrade on a package-manager-controlled \
394             installation."
395                .yellow()
396        );
397    }
398
399    // - The branch is deliberately excluded from both projections. Cargo
400    // - versions carry a branch suffix (`1.29.0-dev`) while ZFVM keeps branch
401    // - and status orthogonal, so the comparison runs on status and number
402    // - only. This matches the naming in `scripts/archive.sh`.
403    let current_version = zfvm_to_semver(number, status)?;
404
405    let latest_tag = if let Some(tag_name) = tag {
406        println!("Upgrading to specified tag: {}", tag_name.green());
407        tag_name
408    } else {
409        // - A custom `--branch` value must be a real ZFVM branch identifier,
410        // - otherwise the tag lookup silently finds nothing.
411        let branch_prefix = if let Some(b) = custom_branch {
412            let canonical = BRANCH_NAMES
413                .iter()
414                .find(|known| known.eq_ignore_ascii_case(b.trim()))
415                .ok_or_else(|| {
416                    anyhow!(
417                        "Unknown branch '{}'. Expected one of: {}",
418                        b,
419                        BRANCH_NAMES.join(", ")
420                    )
421                })?;
422            println!(
423                "Upgrading to latest release from branch: {}",
424                canonical.green()
425            );
426            format!("{canonical}-")
427        } else if branch.eq_ignore_ascii_case("Pub")
428            || branch.eq_ignore_ascii_case("Public")
429        {
430            "Pub-".to_string()
431        } else {
432            "Prod-".to_string()
433        };
434        get_latest_tag(&branch_prefix)?
435    };
436
437    // - Parse through the `zfvm` crate rather than splitting on `-`. A
438    // - positional split of `Prod-Pre-Alpha-0.1.0` yields status `pre` and
439    // - core `Alpha`, neither of which is a valid ZFVM value.
440    let latest = zfvm::symbolic::parse_long(&latest_tag)
441        .map_err(|e| anyhow!("Malformed release tag '{latest_tag}': {e}"))?;
442    let latest_version_str =
443        zfvm_to_semver(&latest.core().to_string(), latest.status().as_str())?;
444
445    if !force
446        && (Version::parse(&latest_version_str)?
447            <= Version::parse(&current_version)?)
448    {
449        println!(
450            "
451{}",
452            "You are already on the latest version!".green()
453        );
454        return Err(anyhow!("already_on_latest"));
455    }
456
457    let (os, arch) = get_platform_info()?;
458
459    let base_url = format!(
460        "https://gitlab.com/{GITLAB_PROJECT_PATH}/-/releases/{latest_tag}/downloads"
461    );
462    let checksums_txt_url = format!("{base_url}/checksums.txt");
463
464    println!("Downloading archive and checksums from: {checksums_txt_url}");
465    let checksums_txt_content =
466        reqwest::blocking::get(&checksums_txt_url)?.text()?;
467
468    let (new_binary_path, _temp_dir_guard) = if force {
469        fallback_full_upgrade(&base_url, &checksums_txt_content, os, arch)?
470    } else {
471        match try_delta_upgrade(
472            &base_url,
473            &checksums_txt_content,
474            os,
475            arch,
476            &current_version,
477            &latest_version_str
478        ) {
479            Ok(res) => res,
480            Err(e) => {
481                println!(
482                    "Delta upgrade failed: {e}. Falling back to full upgrade."
483                );
484                fallback_full_upgrade(
485                    &base_url,
486                    &checksums_txt_content,
487                    os,
488                    arch
489                )?
490            }
491        }
492    };
493
494    println!("Replacing current executable...");
495    self_replace::self_replace(&new_binary_path)?;
496    Ok(())
497}
498
499#[cfg(test)]
500mod tests {
501    use super::zfvm_to_semver;
502
503    /// Projects a version the way `archive.sh` names its bsdiff patches.
504    fn project(number: &str, status: &str) -> String {
505        zfvm_to_semver(number, status).expect("fixture must project")
506    }
507
508    // ZFVM statuses are case-sensitive, so only the exact spelling `Release`
509    // projects to a bare version.
510    #[test]
511    fn release_status_projects_to_a_bare_version() {
512        assert_eq!(project("1.29.0", "Release"), "1.29.0");
513    }
514
515    // `stable` is not a ZFVM status. It predates the vocabulary and is
516    // accepted case-insensitively so those tags keep resolving.
517    #[test]
518    fn legacy_stable_status_projects_to_a_bare_version() {
519        assert_eq!(project("1.29.0", "stable"), "1.29.0");
520        assert_eq!(project("1.29.0", "Stable"), "1.29.0");
521        assert_eq!(project("1.29.0", "STABLE"), "1.29.0");
522    }
523
524    // Every other status becomes a numeric pre-release ordinal, matching the
525    // `zfvm` crate and `archive.sh`. A readable label would sort wrongly
526    // against the others.
527    #[test]
528    fn other_statuses_project_to_ordinals() {
529        assert_eq!(project("0.1.0", "Pre-Alpha"), "0.1.0-0");
530        assert_eq!(project("1.29.0", "Alpha"), "1.29.0-1");
531        assert_eq!(project("1.29.0", "Beta"), "1.29.0-2");
532        assert_eq!(project("1.29.0", "RC"), "1.29.0-3");
533    }
534
535    // Every status must survive the projection and still parse as SemVer,
536    // which is what the upgrade comparison and the bsdiff naming rely on.
537    #[test]
538    fn every_status_projects_to_parseable_semver() {
539        for status in ["Pre-Alpha", "Alpha", "Beta", "RC", "Release"] {
540            let number = if status == "Release" {
541                "1.29.0"
542            } else {
543                "0.1.0"
544            };
545            let projected = project(number, status);
546            assert!(
547                semver::Version::parse(&projected).is_ok(),
548                "{status} projected to unparseable {projected}"
549            );
550        }
551    }
552
553    #[test]
554    fn rejects_unknown_status() {
555        assert!(zfvm_to_semver("1.29.0", "nightly").is_err());
556        assert!(zfvm_to_semver("1.29.0", "Prealpha").is_err());
557        // Case-sensitive: only the exact ZFVM spelling is accepted.
558        assert!(zfvm_to_semver("1.29.0", "release").is_err());
559        assert!(zfvm_to_semver("1.29.0", "beta").is_err());
560    }
561
562    #[test]
563    fn rejects_malformed_version_number() {
564        assert!(zfvm_to_semver("1.29", "Release").is_err());
565        assert!(zfvm_to_semver("1.29.0-rc.1", "Release").is_err());
566        assert!(zfvm_to_semver("", "Release").is_err());
567    }
568}