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}
31
32fn 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
60fn 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
101fn 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
117fn 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
155fn 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
177fn 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
206fn 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(¤t_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
263pub 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(¤t_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 ¤t_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}