use std::path::PathBuf;
use clap::Parser;
use binstalk_downloader::download::{DataVerifier as _, Download, PkgFmt};
use detect_targets::{TARGET, get_desired_targets};
use miette::{IntoDiagnostic, Result, miette};
use tracing::info;
use crate::download::{
DownloadSource, ReleaseVerifier, client, fetch_latest_version, fetch_release_signature,
remote_is_newer,
};
use super::Context;
#[cfg(all(windows, feature = "alertd"))]
pub(crate) mod task;
#[cfg(unix)]
fn check_exe_writable() -> Result<()> {
let exe_path = std::env::current_exe().into_diagnostic()?;
let exe_dir = exe_path
.parent()
.ok_or_else(|| miette!("current exe is not in a directory"))?;
let test_file = exe_dir.join(".bestool_write_test");
if std::fs::write(&test_file, b"test").is_err() {
return Err(miette!(
"Cannot write to executable directory: {}\n\
Please run with sudo: sudo bestool self-update",
exe_dir.display()
));
}
let _ = std::fs::remove_file(test_file);
Ok(())
}
#[cfg(unix)]
pub(crate) fn is_package_manager_install() -> bool {
std::path::Path::new("/usr/share/doc/bestool/copyright").exists()
}
#[cfg(not(unix))]
pub(crate) fn is_package_manager_install() -> bool {
false
}
#[cfg(unix)]
fn check_package_manager_install(force: bool) -> Result<()> {
if is_package_manager_install() && !force {
return Err(miette!(
"bestool appears to be installed via a package manager.\n\
Please use your package manager to update bestool (e.g., 'apt update && apt upgrade bestool').\n\
If you want to override this and self-update anyway, use: bestool self-update --force"
));
}
Ok(())
}
#[derive(Debug, Clone, Parser)]
pub struct SelfUpdateArgs {
#[arg(long, default_value = "latest")]
pub version: String,
#[arg(long)]
pub target: Option<String>,
#[arg(long)]
pub temp_dir: Option<PathBuf>,
#[cfg(windows)]
#[arg(short = 'P', long)]
pub add_to_path: bool,
#[arg(long)]
pub force: bool,
}
pub async fn run(args: SelfUpdateArgs, _ctx: Context) -> Result<()> {
#[cfg(all(windows, feature = "alertd"))]
if is_alertd_service_running().await {
return delegate_to_daemon(&args).await;
}
#[cfg(unix)]
{
check_exe_writable()?;
check_package_manager_install(args.force)?;
}
let SelfUpdateArgs {
version,
target,
temp_dir,
force,
#[cfg(windows)]
add_to_path,
} = args;
#[cfg(windows)]
if add_to_path && let Err(err) = add_self_to_path() {
tracing::error!("{err:?}");
}
match perform_update(&version, target, temp_dir, force).await? {
UpdateOutcome::AlreadyCurrent { version } => {
info!(
version = %version,
"already on the latest version; use --force to reinstall"
);
}
UpdateOutcome::Updated { from, to } => {
info!(from = %from, to = %to, "updated bestool");
}
}
Ok(())
}
#[derive(Debug, Clone)]
pub(crate) enum UpdateOutcome {
AlreadyCurrent { version: String },
Updated { from: String, to: String },
}
pub(crate) async fn perform_update(
version: &str,
target: Option<String>,
temp_dir: Option<PathBuf>,
force: bool,
) -> Result<UpdateOutcome> {
let current = env!("CARGO_PKG_VERSION");
let resolved = if version == "latest" {
let latest = fetch_latest_version().await?;
if !force && !remote_is_newer(current, &latest) {
return Ok(UpdateOutcome::AlreadyCurrent {
version: current.to_string(),
});
}
latest
} else {
version.to_string()
};
info!(from = current, to = %resolved, "updating bestool");
let client = client().await?;
let detected_targets = get_desired_targets(target.map(|t| vec![t]));
let detected_targets = detected_targets.get().await;
let target = detected_targets
.first()
.cloned()
.unwrap_or_else(|| TARGET.into());
let dir = temp_dir.unwrap_or_else(std::env::temp_dir);
let filename = format!(
"bestool{ext}",
ext = if cfg!(windows) { ".exe" } else { "" }
);
let dest = dir.join(&filename);
let _ = tokio::fs::remove_file(&dest).await;
let host = DownloadSource::Tools.host();
let archive_path = format!("/bestool/{resolved}/{target}/{filename}.tar.zst");
let archive_url = host.join(&archive_path).into_diagnostic()?;
info!(url = %archive_url, "downloading and verifying release");
let signature = fetch_release_signature(&client, &archive_url).await?;
let mut verifier = ReleaseVerifier::new(signature);
Download::new_with_data_verifier(client, archive_url, &mut verifier)
.and_extract(PkgFmt::Tzstd, &dir)
.await
.into_diagnostic()?;
if !verifier.validate() {
return Err(miette!(
"release signature verification failed; refusing to install {resolved}"
));
}
info!(?dest, "signature verified, replacing binary");
self_replace::self_replace(&dest).into_diagnostic()?;
let _ = tokio::fs::remove_file(&dest).await;
Ok(UpdateOutcome::Updated {
from: current.to_string(),
to: resolved,
})
}
#[cfg(all(windows, feature = "alertd"))]
async fn delegate_to_daemon(args: &SelfUpdateArgs) -> Result<()> {
use serde_json::Value;
let (client, base_url) =
bestool_alertd::commands::try_connect_daemon(&bestool_alertd::commands::default_server_addrs())
.await?;
let mut url = reqwest::Url::parse(&format!("{base_url}/tasks/self-update/update"))
.into_diagnostic()?;
url.query_pairs_mut()
.append_pair("version", &args.version)
.append_pair("force", if args.force { "true" } else { "false" });
info!("alert daemon is running; delegating update to it");
let response = client
.get(url)
.send()
.await
.map_err(|err| miette!("could not reach the alert daemon: {err}"))?;
let body: Value = response
.json()
.await
.map_err(|err| miette!("unexpected response from the alert daemon: {err}"))?;
if body.get("updating").and_then(Value::as_bool) == Some(true) {
let from = body.get("from").and_then(Value::as_str).unwrap_or("?");
let to = body.get("to").and_then(Value::as_str).unwrap_or("?");
info!(from, to, "alert daemon is updating and will restart");
} else if let Some(message) = body.get("error").and_then(Value::as_str) {
return Err(miette!("alert daemon could not update: {message}"));
} else {
let current = body.get("current").and_then(Value::as_str).unwrap_or("?");
info!(version = current, "alert daemon is already on the latest version");
}
Ok(())
}
#[cfg(windows)]
fn add_self_to_path() -> Result<()> {
let self_path = std::env::current_exe().into_diagnostic()?;
let self_dir = self_path
.parent()
.ok_or_else(|| miette!("current exe is not in a dir?"))?;
let self_dir = self_dir
.to_str()
.ok_or_else(|| miette!("current exe path is not utf-8"))?;
windows_env::prepend("PATH", self_dir).into_diagnostic()?;
Ok(())
}
#[cfg(all(windows, feature = "alertd"))]
async fn is_alertd_service_running() -> bool {
bestool_alertd::commands::try_connect_daemon(&bestool_alertd::commands::default_server_addrs())
.await
.is_ok()
}
#[cfg(all(test, unix))]
mod tests {
use std::fs;
use tempfile::TempDir;
#[test]
fn test_check_exe_writable_with_writable_dir() {
let temp_dir = TempDir::new().unwrap();
let test_file = temp_dir.path().join(".bestool_write_test");
assert!(fs::write(&test_file, b"test").is_ok());
assert!(fs::remove_file(test_file).is_ok());
}
#[test]
fn test_check_exe_writable_with_readonly_dir() {
use std::os::unix::fs::PermissionsExt;
let temp_dir = TempDir::new().unwrap();
let temp_path = temp_dir.path();
let mut perms = fs::metadata(temp_path).unwrap().permissions();
perms.set_mode(0o555);
fs::set_permissions(temp_path, perms).unwrap();
let test_file = temp_path.join(".bestool_write_test");
assert!(fs::write(&test_file, b"test").is_err());
let mut perms = fs::metadata(temp_path).unwrap().permissions();
perms.set_mode(0o755);
let _ = fs::set_permissions(temp_path, perms);
}
}