//! CLI self-updater: queries the latest Gitea Release, downloads the matching //! platform archive, verifies its SHA-256 checksum, and atomically replaces the //! running `guara` binary. //! //! Uses the system `curl` and `tar` binaries to avoid pulling a full TLS stack //! into `guara-core`. use crate::error::{Error, Result}; use serde::Deserialize; use sha2::{Digest, Sha256}; use std::fs; use std::path::PathBuf; use std::process::Command; /// Gitea `releases/latest` API endpoint (`tag_name`, `assets[].name`, `assets[].browser_download_url`). const LATEST_RELEASE_URL: &str = "https://git.lucasrguerra.dev.br/api/v1/repos/lucasrguerra/guara/releases/latest"; /// Human-facing Gitea Releases page for desktop GUI users. const RELEASES_PAGE_URL: &str = "https://git.lucasrguerra.dev.br/lucasrguerra/guara/releases"; #[derive(Debug, Deserialize)] struct Release { tag_name: String, assets: Vec, } #[derive(Debug, Deserialize)] struct Asset { name: String, browser_download_url: String, } #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct UpdateStatus { pub current_version: String, pub latest_version: Option, pub update_available: bool, } fn curl_text(url: &str) -> Result { let out = Command::new("curl") .args(["-fsSL", "-H", "User-Agent: guara-updater", url]) .output() .map_err(|e| Error::msg(format!("curl {url}: {e}")))?; if !out.status.success() { return Err(Error::msg(format!( "curl {url}: {}", String::from_utf8_lossy(&out.stderr) ))); } Ok(String::from_utf8_lossy(&out.stdout).into_owned()) } fn curl_download(url: &str, dest: &PathBuf) -> Result<()> { let status = Command::new("curl") .args(["-fsSL", "-H", "User-Agent: guara-updater", "-o"]) .arg(dest) .arg(url) .status() .map_err(|e| Error::msg(format!("curl {url}: {e}")))?; if !status.success() { return Err(Error::msg(format!("curl {url}: download falhou"))); } Ok(()) } /// Returns the expected release asset suffix for the current OS/architecture /// (e.g. `"-linux-x86_64.tar.gz"`). fn asset_suffix_for_platform() -> String { let os = if cfg!(target_os = "linux") { "linux" } else if cfg!(target_os = "macos") { "macos" } else { "windows" }; let arch = if cfg!(target_arch = "x86_64") { "x86_64" } else { "aarch64" }; let ext = if cfg!(windows) { "zip" } else { "tar.gz" }; format!("-{os}-{arch}.{ext}") } /// Queries the latest published release and compares it against the running /// `CARGO_PKG_VERSION`. pub fn check(current_version: &str) -> Result { let url = LATEST_RELEASE_URL; let body = match curl_text(url) { Ok(b) => b, Err(_) => { // Offline or no published releases yet: treat as no update available. return Ok(UpdateStatus { current_version: current_version.to_string(), latest_version: None, update_available: false, }); } }; let release: Release = serde_json::from_str(&body)?; let latest = release.tag_name.trim_start_matches('v').to_string(); let update_available = latest != current_version; Ok(UpdateStatus { current_version: current_version.to_string(), latest_version: Some(latest), update_available, }) } /// Downloads, verifies, and installs the latest CLI release in place. /// Returns the newly installed version string. pub fn apply(current_exe: &PathBuf) -> Result { // Guard against replacing `guara-gui` with the CLI binary (`guara`); // desktop GUI updates use the installer/package from the release page instead. let is_cli = current_exe .file_stem() .map(|stem| stem == "guara") .unwrap_or(false); if !is_cli { return Err(Error::msg(format!( "a atualização automática vale só para o CLI `guara`; para o app desktop, baixe o instalador em {RELEASES_PAGE_URL}" ))); } let body = curl_text(LATEST_RELEASE_URL)?; let release: Release = serde_json::from_str(&body)?; let suffix = asset_suffix_for_platform(); let asset = release .assets .iter() .find(|a| a.name.ends_with(&suffix)) .ok_or_else(|| { Error::msg(format!( "nenhum asset terminado em `{suffix}` na release {}", release.tag_name )) })?; let checksum_asset = release .assets .iter() .find(|a| a.name == format!("{}.sha256", asset.name)); let tmp_dir = std::env::temp_dir().join("guara-update"); fs::create_dir_all(&tmp_dir)?; let archive_path = tmp_dir.join(&asset.name); curl_download(&asset.browser_download_url, &archive_path)?; if let Some(checksum_asset) = checksum_asset { let expected = curl_text(&checksum_asset.browser_download_url)?; let expected_hash = expected .split_whitespace() .next() .unwrap_or("") .to_lowercase(); let data = fs::read(&archive_path)?; let mut hasher = Sha256::new(); hasher.update(&data); let actual_hash: String = hasher .finalize() .iter() .map(|b| format!("{b:02x}")) .collect(); if actual_hash != expected_hash { return Err(Error::msg( "checksum SHA-256 não confere — atualização descartada", )); } } // Extract the archive (`tar xf` auto-detects `.tar.gz` and `.zip` on modern // libarchive/bsdtar) and atomically replace the current executable. let extract_dir = tmp_dir.join("extracted"); let _ = fs::remove_dir_all(&extract_dir); fs::create_dir_all(&extract_dir)?; let status = Command::new("tar") .arg("xf") .arg(&archive_path) .arg("-C") .arg(&extract_dir) .status()?; if !status.success() { return Err(Error::msg("tar xf: extração falhou")); } let new_binary = find_binary(&extract_dir)?; let backup = current_exe.with_extension("bak"); let _ = fs::remove_file(&backup); fs::rename(current_exe, &backup)?; fs::copy(&new_binary, current_exe)?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let mut perms = fs::metadata(current_exe)?.permissions(); perms.set_mode(0o755); fs::set_permissions(current_exe, perms)?; } Ok(release.tag_name) } /// Locates `name` at the archive root or one directory level below /// (`guara-vX.Y.Z-/guara`). fn find_binary(dir: &PathBuf) -> Result { fn is_binary(name: &std::ffi::OsStr) -> bool { name == "guara" || name == "guara.exe" } for entry in fs::read_dir(dir)? { let entry = entry?; if is_binary(&entry.file_name()) { return Ok(entry.path()); } if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) { for inner in fs::read_dir(entry.path())? { let inner = inner?; if is_binary(&inner.file_name()) { return Ok(inner.path()); } } } } Err(Error::msg("binário não encontrado no pacote baixado")) } #[cfg(test)] mod tests { use super::*; #[test] fn apply_refuses_to_overwrite_the_desktop_app() { let err = apply(&PathBuf::from("/opt/guara/guara-gui")).unwrap_err(); assert!(err.to_string().contains("só para o CLI"), "{err}"); let err = apply(&PathBuf::from("C:/Program Files/Guara/guara-gui.exe")).unwrap_err(); assert!(err.to_string().contains(RELEASES_PAGE_URL), "{err}"); } }