use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use semver::Version;
use super::github::{
available_asset_names, download_asset, download_asset_text, parse_sha256sums, verify_sha256,
GitHubRelease,
};
use super::platform::platform_asset_name;
use super::CURRENT_VERSION;
pub(super) async fn update_cli_binary(release: &GitHubRelease) -> Result<()> {
let asset_name =
platform_asset_name().context("Unsupported platform for binary self-update")?;
let current_exe =
std::env::current_exe().context("Failed to determine current executable path")?;
let current_exe = current_exe
.canonicalize()
.unwrap_or_else(|_| current_exe.clone());
update_target_binary(release, "tuitbot", &asset_name, ¤t_exe).await
}
pub(super) async fn update_target_binary(
release: &GitHubRelease,
binary_name: &str,
asset_name: &str,
target_path: &Path,
) -> Result<()> {
let archive_asset = release
.assets
.iter()
.find(|a| a.name == asset_name)
.with_context(|| {
format!(
"Release '{}' has no asset named '{}'. Available assets: {}",
release.tag_name,
asset_name,
available_asset_names(release)
)
})?;
let checksums_asset = release
.assets
.iter()
.find(|a| a.name == "SHA256SUMS")
.context("Release has no SHA256SUMS asset")?;
let client = reqwest::Client::builder()
.user_agent(format!("tuitbot/{CURRENT_VERSION}"))
.timeout(std::time::Duration::from_secs(120))
.build()?;
eprintln!(" Downloading {asset_name}...");
let (archive_bytes, checksums_text) = tokio::try_join!(
download_asset(&client, &archive_asset.browser_download_url),
download_asset_text(&client, &checksums_asset.browser_download_url),
)?;
let expected_hash = parse_sha256sums(&checksums_text, asset_name)
.context("Could not find checksum for asset in SHA256SUMS")?;
verify_sha256(&archive_bytes, &expected_hash)?;
eprintln!(" SHA256 verified.");
let binary_bytes = extract_named_binary(&archive_bytes, binary_name)?;
replace_binary_at(&binary_bytes, target_path)?;
Ok(())
}
pub(super) fn detect_server_path() -> Option<PathBuf> {
let binary_name = if cfg!(target_os = "windows") {
"tuitbot-server.exe"
} else {
"tuitbot-server"
};
let path_var = std::env::var_os("PATH")?;
for dir in std::env::split_paths(&path_var) {
let candidate = dir.join(binary_name);
if candidate.is_file() {
return Some(candidate);
}
}
None
}
pub(super) fn detect_server_version(server_exe: &Path) -> Option<Version> {
let output = std::process::Command::new(server_exe)
.arg("--version")
.output()
.ok()?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&output.stdout);
parse_server_version_output(stdout.trim())
}
pub(super) fn parse_server_version_output(output: &str) -> Option<Version> {
let line = output.lines().next()?.trim();
let version_str = line.strip_prefix("tuitbot-server ").unwrap_or(line).trim();
Version::parse(version_str).ok()
}
pub(super) fn extract_named_binary(archive_bytes: &[u8], binary_name: &str) -> Result<Vec<u8>> {
#[cfg(not(target_os = "windows"))]
{
extract_from_tar_gz(archive_bytes, binary_name)
}
#[cfg(target_os = "windows")]
{
extract_from_zip(archive_bytes, binary_name)
}
}
#[cfg(not(target_os = "windows"))]
fn extract_from_tar_gz(archive_bytes: &[u8], binary_name: &str) -> Result<Vec<u8>> {
use flate2::read::GzDecoder;
use std::io::Read;
use tar::Archive;
let gz = GzDecoder::new(archive_bytes);
let mut archive = Archive::new(gz);
let target_name = if cfg!(target_os = "windows") {
format!("{binary_name}.exe")
} else {
binary_name.to_string()
};
for entry in archive.entries().context("Failed to read tar entries")? {
let mut entry = entry.context("Failed to read tar entry")?;
let path = entry.path().context("Failed to read entry path")?;
if path
.file_name()
.is_some_and(|name| name == target_name.as_str())
{
let mut buf = Vec::new();
entry
.read_to_end(&mut buf)
.context("Failed to read binary from archive")?;
return Ok(buf);
}
}
bail!("Archive does not contain '{target_name}'")
}
#[cfg(target_os = "windows")]
fn extract_from_zip(archive_bytes: &[u8], binary_name: &str) -> Result<Vec<u8>> {
use std::io::Read;
let target_name = format!("{binary_name}.exe");
let cursor = std::io::Cursor::new(archive_bytes);
let mut archive = zip::ZipArchive::new(cursor).context("Failed to read zip archive")?;
for i in 0..archive.len() {
let mut file = archive.by_index(i).context("Failed to read zip entry")?;
if file.name().ends_with(&target_name) {
let mut buf = Vec::new();
file.read_to_end(&mut buf)
.context("Failed to read binary from zip")?;
return Ok(buf);
}
}
bail!("Archive does not contain '{target_name}'")
}
pub(super) fn replace_binary_at(new_binary: &[u8], target_path: &Path) -> Result<()> {
let parent = target_path
.parent()
.context("Target binary has no parent directory")?;
let stem = target_path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "binary".to_string());
let temp_path = parent.join(format!(".{stem}-update-tmp"));
let old_path = parent.join(format!(".{stem}-old"));
fs::write(&temp_path, new_binary).with_context(|| {
format!(
"Failed to write temporary file: {}\nHint: Check file permissions or try running with sudo.",
temp_path.display()
)
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = fs::Permissions::from_mode(0o755);
fs::set_permissions(&temp_path, perms).context("Failed to set executable permissions")?;
}
if let Err(e) = fs::rename(target_path, &old_path) {
let _ = fs::remove_file(&temp_path);
return Err(e).with_context(|| {
format!(
"Failed to rename current binary.\nHint: You may need elevated permissions to update {}",
target_path.display()
)
});
}
if let Err(e) = fs::rename(&temp_path, target_path) {
let _ = fs::rename(&old_path, target_path);
let _ = fs::remove_file(&temp_path);
return Err(e).context("Failed to install new binary (old binary restored)");
}
let _ = fs::remove_file(&old_path);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_server_version_standard() {
let v = parse_server_version_output("tuitbot-server 0.5.2").unwrap();
assert_eq!(v.major, 0);
assert_eq!(v.minor, 5);
assert_eq!(v.patch, 2);
}
#[test]
fn parse_server_version_no_prefix() {
let v = parse_server_version_output("1.2.3").unwrap();
assert_eq!(v.major, 1);
assert_eq!(v.minor, 2);
assert_eq!(v.patch, 3);
}
#[test]
fn parse_server_version_with_trailing_text() {
let v = parse_server_version_output("tuitbot-server 0.5.2\nsome extra output").unwrap();
assert_eq!(v.minor, 5);
}
#[test]
fn parse_server_version_empty() {
assert!(parse_server_version_output("").is_none());
}
#[test]
fn parse_server_version_invalid() {
assert!(parse_server_version_output("tuitbot-server not-a-version").is_none());
}
#[test]
fn parse_server_version_whitespace() {
let v = parse_server_version_output(" tuitbot-server 2.0.0 ").unwrap();
assert_eq!(v.major, 2);
}
#[test]
fn detect_server_path_returns_option() {
let _ = detect_server_path();
}
#[test]
fn replace_binary_at_creates_new_file() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("test-binary");
fs::write(&target, b"old content").unwrap();
replace_binary_at(b"new content", &target).unwrap();
let content = fs::read_to_string(&target).unwrap();
assert_eq!(content, "new content");
}
#[test]
fn replace_binary_at_cleans_up_old() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("test-bin");
fs::write(&target, b"original").unwrap();
replace_binary_at(b"updated", &target).unwrap();
let old = dir.path().join(".test-bin-old");
assert!(!old.exists());
}
#[test]
fn replace_binary_at_fails_for_nonexistent_target() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("nonexistent");
let result = replace_binary_at(b"data", &target);
assert!(result.is_err());
}
}