use glacier_ui::mlua::{Lua, Result as LuaResult, Table};
use std::{
path::{Path, PathBuf},
process::Command,
sync::Mutex,
};
use update_core::{
is_newer,
release::{
ReleaseAsset, debian_architecture, package_version, parse_release, select_package_assets,
},
};
const REPOSITORY: &str = "antoniofernandodj/rustploy";
const HELPER: &str = "/usr/libexec/rustploy-gui-update";
const INSTALLED_BINARY: &str = "/usr/bin/rustploy-gui";
const CACHE_SUBDIR: &str = "rustploy-gui/updates";
const MAX_DEB_BYTES: u64 = 200 * 1024 * 1024;
const PORTABLE_DIR_NAME: &str = "Rustploy";
const PORTABLE_HELPER: &str = "rustploy-gui-update.exe";
const WINDOWS_PACKAGE: &str = "rustploy-gui-windows";
#[derive(Debug, PartialEq, Eq)]
enum InstallKind {
Deb,
Zip,
Other,
}
impl InstallKind {
fn label(&self) -> &'static str {
match self {
Self::Deb => "deb",
Self::Zip => "zip",
Self::Other => "other",
}
}
}
fn install_kind_of(executable: &Path, portable_dir: Option<&Path>) -> InstallKind {
let Ok(resolved) = std::fs::canonicalize(executable) else {
return InstallKind::Other;
};
if resolved == Path::new(INSTALLED_BINARY) {
return InstallKind::Deb;
}
let in_portable_dir = portable_dir
.and_then(|directory| std::fs::canonicalize(directory).ok())
.is_some_and(|directory| resolved.parent() == Some(directory.as_path()));
if in_portable_dir {
InstallKind::Zip
} else {
InstallKind::Other
}
}
fn portable_dir() -> Option<PathBuf> {
if !cfg!(windows) {
return None;
}
std::env::var_os("LOCALAPPDATA")
.filter(|value| !value.is_empty())
.map(|local| PathBuf::from(local).join(PORTABLE_DIR_NAME))
}
fn staging_dir(install_dir: &Path) -> PathBuf {
let mut name = install_dir.file_name().unwrap_or_default().to_os_string();
name.push("-update");
install_dir.with_file_name(name)
}
fn install_kind() -> InstallKind {
std::env::current_exe()
.map(|executable| install_kind_of(&executable, portable_dir().as_deref()))
.unwrap_or(InstallKind::Other)
}
fn can_update() -> bool {
match install_kind() {
InstallKind::Deb => Path::new(HELPER).exists() && find_in_path("pkexec").is_some(),
InstallKind::Zip => {
portable_dir().is_some_and(|directory| directory.join(PORTABLE_HELPER).is_file())
&& find_in_path("curl").is_some()
}
InstallKind::Other => false,
}
}
fn find_in_path(program: &str) -> Option<PathBuf> {
let file_name = format!("{program}{}", std::env::consts::EXE_SUFFIX);
std::env::var_os("PATH").and_then(|paths| {
std::env::split_paths(&paths)
.map(|directory| directory.join(&file_name))
.find(|candidate| candidate.is_file())
})
}
fn without_console(command: &mut Command) -> &mut Command {
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
command.creation_flags(CREATE_NO_WINDOW);
}
command
}
fn updates_dir() -> Result<PathBuf, String> {
let cache = match std::env::var_os("XDG_CACHE_HOME").filter(|value| !value.is_empty()) {
Some(path) => PathBuf::from(path),
None => PathBuf::from(
std::env::var_os("HOME").ok_or_else(|| "sem HOME para achar o cache".to_string())?,
)
.join(".cache"),
};
let expected = PathBuf::from(std::env::var_os("HOME").unwrap_or_default()).join(".cache");
if cache != expected {
return Err(format!(
"o cache do app precisa estar em {} (XDG_CACHE_HOME aponta para {})",
expected.display(),
cache.display()
));
}
Ok(cache.join(CACHE_SUBDIR))
}
fn download(url: &str, destination: &Path, max_bytes: u64) -> Result<(), String> {
if !url.starts_with("https://github.com/") {
return Err(format!("recusei baixar de fora do GitHub: {url}"));
}
let partial = destination.with_extension("partial");
let status = without_console(&mut Command::new("curl"))
.args([
"--fail",
"--silent",
"--show-error",
"--location",
"--proto",
"=https",
"--proto-redir",
"=https",
"--max-redirs",
"5",
"--connect-timeout",
"20",
"--max-time",
"600",
"--max-filesize",
])
.arg(max_bytes.to_string())
.arg("--output")
.arg(&partial)
.arg(url)
.output()
.map_err(|error| format!("não consegui rodar o curl: {error}"))?;
if !status.status.success() {
let _ = std::fs::remove_file(&partial);
return Err(format!(
"o download falhou: {}",
String::from_utf8_lossy(&status.stderr).trim()
));
}
std::fs::rename(&partial, destination).map_err(|error| format!("gravando o download: {error}"))
}
fn validate_package_name(name: &str, start: &str, end: &str) -> Result<(), String> {
let plain = name
.chars()
.all(|character| character.is_ascii_alphanumeric() || "._+-~".contains(character));
if plain && name.starts_with(start) && name.ends_with(end) && !name.contains("..") {
Ok(())
} else {
Err(format!("nome de pacote inesperado: {name}"))
}
}
fn validate_deb_name(deb_name: &str) -> Result<(), String> {
validate_package_name(deb_name, "rustploy-gui_", ".deb")
}
fn validate_zip_name(zip_name: &str) -> Result<(), String> {
validate_package_name(zip_name, &format!("{WINDOWS_PACKAGE}-v"), ".zip")
}
fn select_windows_zip(
assets: &[ReleaseAsset],
tag: &str,
) -> Result<(ReleaseAsset, ReleaseAsset), String> {
let allowed_prefix = format!("https://github.com/{REPOSITORY}/releases/download/");
let find = |name: &str| {
assets
.iter()
.find(|asset| asset.name == name && asset.url.starts_with(&allowed_prefix))
.cloned()
};
let zip_name = format!("{WINDOWS_PACKAGE}-{tag}.zip");
let zip = find(&zip_name)
.ok_or_else(|| format!("a release {tag} não tem o zip do Windows ({zip_name})"))?;
let signature = find(&format!("{zip_name}.minisig")).ok_or_else(|| {
format!(
"a release {tag} não tem a assinatura ({zip_name}.minisig); só instalo pacote assinado"
)
})?;
Ok((zip, signature))
}
struct PendingSwap {
helper: PathBuf,
install_dir: PathBuf,
zip: PathBuf,
signature: PathBuf,
}
static PENDING_SWAP: Mutex<Option<PendingSwap>> = Mutex::new(None);
fn install(package_url: &str, signature_url: &str, package_name: &str) -> Result<String, String> {
if !can_update() {
return Err("este app não foi instalado por .deb nem pelo install.ps1 (ou falta o pkexec ou o curl): atualize pelo comando de instalação".into());
}
match install_kind() {
InstallKind::Zip => prepare_portable(package_url, signature_url, package_name),
_ => install_deb(package_url, signature_url, package_name),
}
}
fn prepare_portable(zip_url: &str, signature_url: &str, zip_name: &str) -> Result<String, String> {
validate_zip_name(zip_name)?;
let install_dir = portable_dir().ok_or_else(|| "sem LOCALAPPDATA".to_string())?;
let staging = staging_dir(&install_dir);
if staging.exists() {
std::fs::remove_dir_all(&staging)
.map_err(|error| format!("apagando {}: {error}", staging.display()))?;
}
std::fs::create_dir_all(&staging)
.map_err(|error| format!("criando {}: {error}", staging.display()))?;
let zip = staging.join(zip_name);
let signature = staging.join(format!("{zip_name}.minisig"));
download(zip_url, &zip, MAX_DEB_BYTES)?;
download(signature_url, &signature, 8 * 1024)?;
let helper = staging.join(PORTABLE_HELPER);
std::fs::copy(install_dir.join(PORTABLE_HELPER), &helper)
.map_err(|error| format!("copiando o {PORTABLE_HELPER}: {error}"))?;
let output = without_console(&mut Command::new(&helper))
.arg("check")
.arg(&install_dir)
.arg(&zip)
.arg(&signature)
.output()
.map_err(|error| format!("não consegui rodar o {PORTABLE_HELPER}: {error}"))?;
if !output.status.success() {
return Err(last_line(&output.stderr));
}
*PENDING_SWAP
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(PendingSwap {
helper,
install_dir,
zip,
signature,
});
Ok("conferido".to_string())
}
fn last_line(stderr: &[u8]) -> String {
let detail = String::from_utf8_lossy(stderr);
detail
.lines()
.rev()
.find(|line| !line.trim().is_empty())
.unwrap_or("falhou")
.trim()
.to_string()
}
fn take_last_result() -> Option<(bool, String)> {
let path = staging_dir(&portable_dir()?).join("result.json");
let text = std::fs::read_to_string(&path).ok()?;
let _ = std::fs::remove_file(&path);
parse_last_result(&text)
}
fn parse_last_result(text: &str) -> Option<(bool, String)> {
let json: serde_json::Value = serde_json::from_str(text).ok()?;
if json["ok"].as_bool()? {
let version = json["version"].as_str().unwrap_or("?");
Some((true, format!("App atualizado (release v{version}).")))
} else {
let message = json["message"].as_str().unwrap_or("falhou");
Some((false, format!("A atualização do app falhou: {message}")))
}
}
fn install_deb(deb_url: &str, signature_url: &str, deb_name: &str) -> Result<String, String> {
validate_deb_name(deb_name)?;
let directory = updates_dir()?;
std::fs::create_dir_all(&directory)
.map_err(|error| format!("criando {}: {error}", directory.display()))?;
if let Ok(entries) = std::fs::read_dir(&directory) {
for entry in entries.flatten() {
let _ = std::fs::remove_file(entry.path());
}
}
let deb_path = directory.join(deb_name);
let signature_path = directory.join(format!("{deb_name}.minisig"));
download(deb_url, &deb_path, MAX_DEB_BYTES)?;
download(signature_url, &signature_path, 8 * 1024)?;
let output = Command::new("pkexec")
.arg(HELPER)
.arg("install")
.arg(&deb_path)
.arg(&signature_path)
.output()
.map_err(|error| format!("não consegui rodar o pkexec: {error}"))?;
let _ = std::fs::remove_file(&deb_path);
let _ = std::fs::remove_file(&signature_path);
match output.status.code() {
Some(0) => Ok("instalado".to_string()),
Some(126) | Some(127) => Err("a autenticação foi cancelada ou negada".into()),
_ => Err(last_line(&output.stderr)),
}
}
fn restart() -> Result<(), String> {
let pending = PENDING_SWAP
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.take();
if let Some(pending) = pending {
without_console(&mut Command::new(&pending.helper))
.arg("swap")
.arg(&pending.install_dir)
.arg(&pending.zip)
.arg(&pending.signature)
.spawn()
.map_err(|error| format!("não consegui rodar o {PORTABLE_HELPER}: {error}"))?;
std::process::exit(0);
}
Command::new(INSTALLED_BINARY)
.spawn()
.map_err(|error| format!("não consegui reabrir o app: {error}"))?;
std::process::exit(0);
}
pub(crate) fn install_lua(lua: &Lua) -> LuaResult<()> {
let table: Table = lua.create_table()?;
table.set(
"info",
lua.create_function(|lua, ()| {
let info = lua.create_table()?;
info.set("version", env!("CARGO_PKG_VERSION"))?;
info.set("os", std::env::consts::OS)?;
info.set("install_kind", install_kind().label())?;
info.set("can_update", can_update())?;
Ok(info)
})?,
)?;
table.set(
"plan",
lua.create_function(|lua, json: String| {
let plan = (|| -> Result<Table, String> {
let release = parse_release(&json)?;
let architecture = debian_architecture()?;
let package =
package_version(&release.assets, REPOSITORY, "rustploy-gui", architecture)
.ok_or_else(|| {
format!(
"a release {} não traz um pacote do app para {architecture}",
release.tag
)
})?;
let is_update_available = is_newer(&package.upstream, env!("CARGO_PKG_VERSION"))
.map_err(|error| error.to_string())?;
let result = lua.create_table().map_err(|error| error.to_string())?;
let set = |key: &str, value: String| {
result.set(key, value).map_err(|error| error.to_string())
};
set("tag", release.tag.clone())?;
set("latest_version", package.upstream.clone())?;
set("html_url", release.html_url.clone())?;
result
.set("is_update_available", is_update_available)
.map_err(|error| error.to_string())?;
if is_update_available {
let (package, signature) = if cfg!(windows) {
select_windows_zip(&release.assets, &release.tag)?
} else {
select_package_assets(
&release.assets,
REPOSITORY,
"rustploy-gui",
debian_architecture()?,
&release.tag,
)?
};
set("deb_url", package.url)?;
set("deb_name", package.name)?;
set("signature_url", signature.url)?;
}
Ok(result)
})();
Ok(match plan {
Ok(table) => (Some(table), None),
Err(message) => (None, Some(message)),
})
})?,
)?;
table.set(
"install",
lua.create_function(
|_, (deb_url, signature_url, deb_name): (String, String, String)| {
Ok(match install(&deb_url, &signature_url, &deb_name) {
Ok(message) => (true, message),
Err(message) => (false, message),
})
},
)?,
)?;
table.set("restart", lua.create_function(|_, ()| Ok(restart().err()))?)?;
table.set(
"last_result",
lua.create_function(|_, ()| {
Ok(match take_last_result() {
Some((ok, message)) => (Some(ok), Some(message)),
None => (None, None),
})
})?,
)?;
lua.globals().set("app_update", table)
}
#[cfg(test)]
mod tests {
use super::*;
use glacier_ui::mlua::Value;
#[test]
fn only_the_installed_path_counts_as_deb() {
assert_eq!(
install_kind_of(Path::new("/nao/existe/rustploy-gui"), None),
InstallKind::Other
);
assert_eq!(
install_kind_of(Path::new("/usr/bin/true"), None),
InstallKind::Other
);
assert_eq!(InstallKind::Deb.label(), "deb");
assert_eq!(InstallKind::Zip.label(), "zip");
assert_eq!(InstallKind::Other.label(), "other");
}
#[test]
fn only_an_executable_directly_in_the_portable_dir_counts_as_zip() {
let temp = std::env::temp_dir().join(format!("rustploy-gui-kind-{}", std::process::id()));
let portable = temp.join("Rustploy");
std::fs::create_dir_all(portable.join("sub")).unwrap();
for file in [
portable.join("rustploy-gui.exe"),
portable.join("sub/rustploy-gui.exe"),
] {
std::fs::write(&file, "").unwrap();
}
let elsewhere = temp.join("rustploy-gui.exe");
std::fs::write(&elsewhere, "").unwrap();
let kind = |executable: &Path| install_kind_of(executable, Some(&portable));
assert_eq!(kind(&portable.join("rustploy-gui.exe")), InstallKind::Zip);
assert_eq!(
kind(&portable.join("sub/rustploy-gui.exe")),
InstallKind::Other
);
assert_eq!(kind(&elsewhere), InstallKind::Other);
assert_eq!(
install_kind_of(&portable.join("rustploy-gui.exe"), None),
InstallKind::Other
);
let _ = std::fs::remove_dir_all(&temp);
}
#[test]
fn staging_is_next_to_the_portable_dir() {
assert_eq!(
staging_dir(Path::new("/x/Local/Rustploy")),
PathBuf::from("/x/Local/Rustploy-update")
);
}
#[test]
fn zip_names_are_validated_before_becoming_paths() {
assert!(validate_zip_name("rustploy-gui-windows-v0.1.22.zip").is_ok());
for bad in [
"rustploy-gui-windows-v0.1.22.zip/../x.zip",
"rustploy-gui-windows-v..zip",
"rustploy-gui_0.1.22-1_amd64.deb",
"outro-v0.1.22.zip",
"rustploy-gui-windows-v0.1.22.zip.minisig",
] {
assert!(
validate_zip_name(bad).is_err(),
"{bad:?} deveria ser recusado"
);
}
}
#[test]
fn the_windows_zip_is_picked_by_tag_and_only_from_the_repository() {
let json = release_json("v9.9.9").replace(
"\n ]}",
&format!(
r#",
{{"name":"rustploy-gui-windows-v9.9.9.zip","browser_download_url":"https://github.com/{REPOSITORY}/releases/download/v9.9.9/rustploy-gui-windows-v9.9.9.zip"}},
{{"name":"rustploy-gui-windows-v9.9.9.zip.minisig","browser_download_url":"https://github.com/{REPOSITORY}/releases/download/v9.9.9/rustploy-gui-windows-v9.9.9.zip.minisig"}}
]}}"#
),
);
let release = parse_release(&json).unwrap();
let (zip, signature) = select_windows_zip(&release.assets, "v9.9.9").unwrap();
assert_eq!(zip.name, "rustploy-gui-windows-v9.9.9.zip");
assert_eq!(signature.name, "rustploy-gui-windows-v9.9.9.zip.minisig");
let unsigned: Vec<_> = release
.assets
.iter()
.filter(|asset| !asset.name.ends_with(".zip.minisig"))
.cloned()
.collect();
let error = select_windows_zip(&unsigned, "v9.9.9").unwrap_err();
assert!(error.contains("só instalo pacote assinado"), "{error}");
let foreign: Vec<_> = release
.assets
.iter()
.map(|asset| ReleaseAsset {
name: asset.name.clone(),
url: asset.url.replace("github.com/", "github.com/outro/"),
})
.collect();
assert!(select_windows_zip(&foreign, "v9.9.9").is_err());
}
#[test]
fn the_last_windows_result_becomes_a_message() {
assert_eq!(
parse_last_result(r#"{"ok":true,"version":"0.1.22"}"#),
Some((true, "App atualizado (release v0.1.22).".to_string()))
);
let (ok, message) =
parse_last_result(r#"{"ok":false,"message":"o app não fechou"}"#).unwrap();
assert!(!ok);
assert!(message.contains("o app não fechou"));
assert_eq!(parse_last_result("lixo"), None);
}
#[test]
fn deb_names_are_validated_before_becoming_paths() {
assert!(validate_deb_name("rustploy-gui_0.1.21-1_amd64.deb").is_ok());
for bad in [
"",
"rustployd_0.1.11-1_amd64.deb",
"rustploy-gui_x/../../etc/passwd.deb",
"rustploy-gui_a b.deb",
"rustploy-gui_..deb",
"rustploy-gui_0.1.21-1_amd64.deb;rm",
"rustploy-gui_0.1.21-1_amd64.tar",
] {
assert!(
validate_deb_name(bad).is_err(),
"{bad:?} deveria ser recusado"
);
}
}
#[test]
fn downloads_only_from_github() {
let destination = std::env::temp_dir().join("rustploy-gui-never");
for url in [
"http://github.com/x",
"https://evil.example/x",
"file:///etc/passwd",
"ftp://github.com/x",
] {
let error = download(url, &destination, 1024).unwrap_err();
assert!(error.contains("fora do GitHub"), "{url}: {error}");
}
assert!(!destination.exists());
}
#[test]
fn updates_dir_must_match_where_the_helper_looks() {
unsafe {
std::env::set_var("HOME", "/home/pessoa");
std::env::set_var("XDG_CACHE_HOME", "/outro/lugar");
}
let error = updates_dir().unwrap_err();
assert!(error.contains("/home/pessoa/.cache"), "{error}");
unsafe { std::env::set_var("XDG_CACHE_HOME", "/home/pessoa/.cache") };
assert_eq!(
updates_dir().unwrap(),
PathBuf::from("/home/pessoa/.cache/rustploy-gui/updates")
);
unsafe { std::env::remove_var("XDG_CACHE_HOME") };
assert_eq!(
updates_dir().unwrap(),
PathBuf::from("/home/pessoa/.cache/rustploy-gui/updates")
);
}
fn run_plan(json: &str) -> (Lua, Option<Table>, Option<String>) {
let lua = Lua::new();
install_lua(&lua).unwrap();
let native: Table = lua.globals().get("app_update").unwrap();
let function: glacier_ui::mlua::Function = native.get("plan").unwrap();
let (plan, error) = function
.call::<(Option<Table>, Option<String>)>(json.to_string())
.unwrap();
(lua, plan, error)
}
fn release_json(tag: &str) -> String {
release_json_with(tag, tag.trim_start_matches('v'))
}
fn release_json_with(tag: &str, app_version: &str) -> String {
let base = format!("https://github.com/{REPOSITORY}/releases/download/{tag}");
format!(
r#"{{"tag_name":"{tag}","html_url":"https://github.com/{REPOSITORY}/releases/tag/{tag}","assets":[
{{"name":"rustploy-gui_{app_version}-1_amd64.deb","browser_download_url":"{base}/rustploy-gui_{app_version}-1_amd64.deb"}},
{{"name":"rustploy-gui_{app_version}-1_amd64.deb.minisig","browser_download_url":"{base}/rustploy-gui_{app_version}-1_amd64.deb.minisig"}},
{{"name":"rustploy-gui_{app_version}-1_arm64.deb","browser_download_url":"{base}/rustploy-gui_{app_version}-1_arm64.deb"}},
{{"name":"rustploy-gui_{app_version}-1_arm64.deb.minisig","browser_download_url":"{base}/rustploy-gui_{app_version}-1_arm64.deb.minisig"}}
]}}"#
)
}
#[test]
fn plan_picks_this_architectures_package_and_its_signature() {
let (_lua, plan, error) = run_plan(&release_json("v9.9.9"));
assert_eq!(error, None);
let plan = plan.unwrap();
assert!(plan.get::<bool>("is_update_available").unwrap());
assert_eq!(plan.get::<String>("latest_version").unwrap(), "9.9.9");
let architecture = debian_architecture().unwrap();
let deb_name: String = plan.get("deb_name").unwrap();
assert_eq!(deb_name, format!("rustploy-gui_9.9.9-1_{architecture}.deb"));
assert!(
plan.get::<String>("signature_url")
.unwrap()
.ends_with(".deb.minisig")
);
assert!(
plan.get::<String>("deb_url")
.unwrap()
.starts_with("https://github.com/")
);
}
#[test]
fn plan_for_an_older_or_equal_release_has_no_files() {
let (_lua, plan, error) =
run_plan(&release_json(&format!("v{}", env!("CARGO_PKG_VERSION"))));
assert_eq!(error, None);
let plan = plan.unwrap();
assert!(!plan.get::<bool>("is_update_available").unwrap());
assert!(matches!(plan.get::<Value>("deb_url").unwrap(), Value::Nil));
}
#[test]
fn the_release_tag_does_not_decide_only_the_app_package_does() {
let running = env!("CARGO_PKG_VERSION");
let (_lua, plan, error) = run_plan(&release_json_with("v9.9.9", running));
assert_eq!(error, None);
let plan = plan.unwrap();
assert!(!plan.get::<bool>("is_update_available").unwrap());
assert_eq!(plan.get::<String>("latest_version").unwrap(), running);
let (_lua, plan, _) = run_plan(&release_json_with("v0.0.1", "9.9.9"));
assert!(plan.unwrap().get::<bool>("is_update_available").unwrap());
}
#[test]
fn a_release_without_an_app_package_is_an_error() {
let json = release_json("v9.9.9").replace("rustploy-gui_", "outro-produto_");
let (_lua, plan, error) = run_plan(&json);
assert!(plan.is_none());
assert!(error.unwrap().contains("não traz um pacote do app"));
}
#[test]
fn plan_reports_garbage_as_an_error_message() {
for json in ["<html>", "{}", r#"{"tag_name":"nightly","html_url":"x"}"#] {
let (_lua, plan, error) = run_plan(json);
assert!(plan.is_none(), "{json}");
assert!(error.is_some(), "{json}");
}
}
#[test]
fn plan_without_a_signature_in_the_release_is_refused() {
let json = release_json("v9.9.9").replace(".deb.minisig", ".deb.sig");
let (_lua, plan, error) = run_plan(&json);
assert!(plan.is_none());
assert!(error.unwrap().contains("só instalo pacote assinado"));
}
}