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
19const GITLAB_PROJECT_PATH: &str = "zillowe/zillwen/zusty/zoi";
21const GITLAB_PROJECT_ID: &str = "71087662";
23
24#[derive(Debug, Deserialize)]
26struct GitLabRelease {
27 tag_name: String
29}
30
31fn 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
59fn 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
100fn 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
116fn 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
154fn 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
176fn 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
205fn 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(¤t_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
262pub 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 ¤t_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 ¤t_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}