Files
guara/crates/guara-core/src/update.rs
T
Lucas RayanandClaude Sonnet 5 76e4466652 feat(update): unify the updater with Sabiá's Gitea-based mechanism
Replaces the signed tauri-plugin-updater (latest.json manifest, minisign
key pair) with the same simple mechanism Sabiá already uses: query the
Gitea Releases API directly, download the matching platform archive,
verify its SHA-256 checksum, and swap the binary in place. The CI job
that built the old latest.json manifest is removed in the next commit,
alongside an unrelated runner-routing change to the same workflow file.

- New guara-core::update module (check/apply), ported from
  sabia-core::update and adapted to Guará's own release asset naming
  (guara-<tag>-<platform>.tar.gz, matching scripts/package-cli.sh).
- New Engine::update_check / Engine::update_apply, wired into the RPC
  service for the browser UI.
- New CLI subcommand: "guara update" (check) and "guara update --apply"
  (download, verify, replace), mirroring "sabia update".
- GUI: replaced the Tauri plugin-updater flow (download_and_install,
  update-progress events, in-app restart) with a call to the new
  update_check/update_apply commands. The desktop app can no longer
  self-install in one click — it now points the user to the Releases
  page, same as Sabiá's desktop app already does.
- Removed tauri-plugin-updater, createUpdaterArtifacts and the
  updater.pubkey config, the signing-key loading in build-gui.sh, the
  CI step testing the old latest.json generator, and the now-unused
  make-latest-json.py/test_make_latest_json.py scripts.
- Documented the break for existing installs in CHANGELOG.md /
  CHANGELOG.pt-BR.md: versions up to 1.3.2 won't find this release
  automatically and need one manual download.

Trade-off accepted knowingly: SHA-256 verifies integrity against
Gitea's own API but, unlike the retired signed mechanism, does not
protect against a compromised Gitea server independently of it. This
is Lucas's own self-hosted instance, not a third-party mirror.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAyr8d7HuAdUGB6aA5xmDD
2026-10-30 22:03:28 -03:00

238 lines
7.7 KiB
Rust

//! 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<Asset>,
}
#[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<String>,
pub update_available: bool,
}
fn curl_text(url: &str) -> Result<String> {
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<UpdateStatus> {
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<String> {
// 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-<platform>/guara`).
fn find_binary(dir: &PathBuf) -> Result<PathBuf> {
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}");
}
}