diff --git a/src-tauri/src/binaries/binaries_manager.rs b/src-tauri/src/binaries/binaries_manager.rs index d564bd0d3..52ce07a26 100644 --- a/src-tauri/src/binaries/binaries_manager.rs +++ b/src-tauri/src/binaries/binaries_manager.rs @@ -22,7 +22,11 @@ use anyhow::{Error, anyhow}; use log::{debug, error, info, warn}; use serde::{Deserialize, Serialize}; -use std::{collections::HashMap, path::PathBuf}; +use std::{ + collections::HashMap, + fs, + path::{Path, PathBuf}, +}; use tari_common::configuration::Network; use tari_shutdown::Shutdown; use tauri_plugin_sentry::sentry; @@ -448,6 +452,24 @@ impl BinaryManager { self.selected_version.clone() } + pub fn get_binary_folder(&self) -> Result { + self.adapter.get_binary_folder() + } + + pub async fn cleanup_old_binary_versions( + &self, + retained_versions: Vec, + ) -> Result, Error> { + let binary_folder = self.get_binary_folder()?; + let binary_name = self.binary_name.clone(); + + tokio::task::spawn_blocking(move || { + cleanup_old_binary_versions(&binary_folder, &retained_versions, &binary_name) + }) + .await + .map_err(|error| anyhow!("Old binary cleanup task failed: {error:?}"))? + } + pub fn get_base_dir(&self) -> Result { let selected_version = self.selected_version.clone(); let binary_folder_path = self.adapter.get_binary_folder()?; @@ -491,3 +513,148 @@ fn check_binary_exists(path: &std::path::Path) -> bool { false }) } + +fn cleanup_old_binary_versions( + binary_folder: &Path, + retained_versions: &[String], + binary_name: &str, +) -> Result, Error> { + if !binary_folder.try_exists()? { + return Ok(Vec::new()); + } + + let mut removed_paths = Vec::new(); + for entry in fs::read_dir(binary_folder)? { + let entry = match entry { + Ok(entry) => entry, + Err(error) => { + warn!( + target: LOG_TARGET_APP_LOGIC, + "Unable to read binary folder entry for {binary_name}. Error: {error:?}" + ); + continue; + } + }; + + let file_type = match entry.file_type() { + Ok(file_type) => file_type, + Err(error) => { + warn!( + target: LOG_TARGET_APP_LOGIC, + "Unable to read binary folder entry type for {binary_name}. Path: {:?}, Error: {error:?}", + entry.path() + ); + continue; + } + }; + + if !file_type.is_dir() { + continue; + } + + let folder_name = entry.file_name(); + let folder_name = folder_name.to_string_lossy(); + if retained_versions + .iter() + .any(|version| version == folder_name.as_ref()) + || !is_binary_version_folder_name(&folder_name) + { + continue; + } + + let path = entry.path(); + match fs::remove_dir_all(&path) { + Ok(()) => { + info!( + target: LOG_TARGET_APP_LOGIC, + "Removed old binary version folder for {binary_name}: {}", + path.display() + ); + removed_paths.push(path); + } + Err(error) => { + warn!( + target: LOG_TARGET_APP_LOGIC, + "Failed to remove old binary version folder for {binary_name}: {}. Error: {error:?}", + path.display() + ); + } + } + } + + Ok(removed_paths) +} + +fn is_binary_version_folder_name(folder_name: &str) -> bool { + let version = folder_name + .strip_prefix('v') + .or_else(|| folder_name.strip_prefix('V')) + .unwrap_or(folder_name); + + version.contains('.') + && version.chars().next().is_some_and(|c| c.is_ascii_digit()) + && version + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | '+')) +} + +#[cfg(test)] +mod tests { + use super::{cleanup_old_binary_versions, is_binary_version_folder_name}; + use tempfile::tempdir; + + #[test] + fn cleanup_old_binary_versions_keeps_retained_versions() { + let temp_dir = tempdir().expect("create temp dir"); + let binaries_dir = temp_dir.path(); + let current_dir = binaries_dir.join("1.2.3"); + let shared_current_dir = binaries_dir.join("1.2.2"); + let another_old_dir = binaries_dir.join("1.2.1"); + let cache_dir = binaries_dir.join("download-cache"); + let checksum_file = binaries_dir.join("latest.sha256"); + + std::fs::create_dir_all(¤t_dir).expect("create current version dir"); + std::fs::create_dir_all(&shared_current_dir).expect("create shared current version dir"); + std::fs::create_dir_all(&another_old_dir).expect("create another old version dir"); + std::fs::create_dir_all(&cache_dir).expect("create non-version dir"); + std::fs::write(&checksum_file, "checksum").expect("create sibling file"); + + let retained_versions = vec!["1.2.3".to_string(), "1.2.2".to_string()]; + let removed_paths = cleanup_old_binary_versions( + binaries_dir, + &retained_versions, + "test-binary", + ) + .expect("cleanup succeeds"); + + assert!(current_dir.exists()); + assert!(shared_current_dir.exists()); + assert!(cache_dir.exists()); + assert!(checksum_file.exists()); + assert!(!another_old_dir.exists()); + assert_eq!(removed_paths.len(), 1); + } + + #[test] + fn cleanup_old_binary_versions_ignores_missing_parent() { + let temp_dir = tempdir().expect("create temp dir"); + let missing_dir = temp_dir.path().join("missing"); + let retained_versions = vec!["1.2.3".to_string()]; + + let removed_paths = + cleanup_old_binary_versions(&missing_dir, &retained_versions, "test-binary") + .expect("cleanup succeeds"); + + assert!(removed_paths.is_empty()); + } + + #[test] + fn is_binary_version_folder_name_only_accepts_version_like_names() { + assert!(is_binary_version_folder_name("5.2.1")); + assert!(is_binary_version_folder_name("v6.25.0")); + assert!(is_binary_version_folder_name("1.0.0-rc.1")); + assert!(!is_binary_version_folder_name("download-cache")); + assert!(!is_binary_version_folder_name("2026-backup")); + assert!(!is_binary_version_folder_name("latest")); + } +} diff --git a/src-tauri/src/binaries/binaries_resolver.rs b/src-tauri/src/binaries/binaries_resolver.rs index 4301b89b2..af125dd67 100644 --- a/src-tauri/src/binaries/binaries_resolver.rs +++ b/src-tauri/src/binaries/binaries_resolver.rs @@ -23,7 +23,7 @@ use crate::LOG_TARGET_APP_LOGIC; use crate::progress_trackers::progress_stepper::IncrementalProgressTracker; use anyhow::{Error, anyhow}; use async_trait::async_trait; -use log::debug; +use log::{debug, warn}; use std::collections::HashMap; use std::path::PathBuf; use std::sync::LazyLock; @@ -263,6 +263,7 @@ impl BinaryResolver { if manager.check_if_files_for_version_exist() { // If files already exist, we can skip the download + self.cleanup_old_binary_versions(manager).await; return Ok(()); } @@ -277,6 +278,7 @@ impl BinaryResolver { let _lock = TARI_SUITE_DOWNLOAD_LOCK.lock().await; if manager.check_if_files_for_version_exist() { + self.cleanup_old_binary_versions(manager).await; return Ok(()); } manager @@ -288,6 +290,8 @@ impl BinaryResolver { .await?; } + self.cleanup_old_binary_versions(manager).await; + Ok(()) } @@ -297,4 +301,54 @@ impl BinaryResolver { .unwrap_or_else(|| panic!("Couldn't find manager for binary: {}", binary.name())) .get_selected_version() } + + async fn cleanup_old_binary_versions(&self, manager: &BinaryManager) { + let retained_versions = self.retained_versions_for_binary_folder(manager); + + match manager.cleanup_old_binary_versions(retained_versions).await { + Ok(removed_paths) => { + if !removed_paths.is_empty() { + debug!( + target: LOG_TARGET_APP_LOGIC, + "Removed {} old binary version folder(s)", + removed_paths.len() + ); + } + } + Err(error) => { + warn!( + target: LOG_TARGET_APP_LOGIC, + "Old binary version cleanup failed. Error: {error:?}" + ); + } + } + } + + fn retained_versions_for_binary_folder(&self, manager: &BinaryManager) -> Vec { + let binary_folder = match manager.get_binary_folder() { + Ok(path) => path, + Err(error) => { + warn!( + target: LOG_TARGET_APP_LOGIC, + "Unable to get binary folder for retained version lookup. Error: {error:?}" + ); + return vec![manager.get_selected_version()]; + } + }; + + self.managers + .values() + .filter_map(|candidate| match candidate.get_binary_folder() { + Ok(folder) if folder == binary_folder => Some(candidate.get_selected_version()), + Ok(_) => None, + Err(error) => { + warn!( + target: LOG_TARGET_APP_LOGIC, + "Unable to inspect binary folder while collecting retained versions. Error: {error:?}" + ); + None + } + }) + .collect() + } }