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
20const GITLAB_PROJECT_PATH: &str = "zillowe/zillwen/zusty/zoi";
22const GITLAB_PROJECT_ID: &str = "71087662";
24
25#[derive(Debug, Deserialize)]
27struct GitLabRelease {
28 tag_name: String,
30 #[serde(default)]
32 created_at: String
33}
34
35const BRANCH_NAMES: [&str; 4] = ["Prod", "Dev", "Spec", "Pub"];
41
42fn zfvm_to_semver(number: &str, status: &str) -> Result<String> {
56 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 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
85fn 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 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
117fn 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
158fn 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
174fn 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
212fn 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
234fn 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
263fn 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(¤t_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
320pub 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 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 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 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(¤t_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 ¤t_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 fn project(number: &str, status: &str) -> String {
505 zfvm_to_semver(number, status).expect("fixture must project")
506 }
507
508 #[test]
511 fn release_status_projects_to_a_bare_version() {
512 assert_eq!(project("1.29.0", "Release"), "1.29.0");
513 }
514
515 #[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 #[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 #[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 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}