//! Version scanner for available agent binaries //! //! Scans a downloads directory for agent binaries and parses version info //! from filenames in the format: gururmm-agent-{os}-{arch}-{version}[.exe] use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::Arc; use anyhow::Result; use semver::Version; use tokio::sync::RwLock; use tracing::{debug, error, info, warn}; /// Information about an available agent version #[derive(Debug, Clone)] pub struct AvailableVersion { /// Semantic version pub version: Version, /// Operating system (linux, windows) pub os: String, /// Architecture (amd64, arm64) pub arch: String, /// Filename on disk pub filename: String, /// Full download URL pub download_url: String, /// SHA256 checksum pub checksum_sha256: String, /// File size in bytes pub file_size: u64, } /// Manages available agent versions pub struct UpdateManager { /// Directory containing agent binaries downloads_dir: PathBuf, /// Base URL for downloads base_url: String, /// Cached available versions, keyed by "os-arch" versions: Arc>>>, /// Whether auto-updates are enabled pub auto_update_enabled: bool, /// Update timeout in seconds pub update_timeout_secs: u64, } impl UpdateManager { /// Create a new UpdateManager pub fn new( downloads_dir: PathBuf, base_url: String, auto_update_enabled: bool, update_timeout_secs: u64, ) -> Self { Self { downloads_dir, base_url, versions: Arc::new(RwLock::new(HashMap::new())), auto_update_enabled, update_timeout_secs, } } /// Scan the downloads directory for available agent binaries pub async fn scan_versions(&self) -> Result<()> { let mut versions: HashMap> = HashMap::new(); if !self.downloads_dir.exists() { warn!("Downloads directory does not exist: {:?}", self.downloads_dir); return Ok(()); } let entries = std::fs::read_dir(&self.downloads_dir)?; for entry in entries.flatten() { let path = entry.path(); if !path.is_file() { continue; } let filename = match path.file_name().and_then(|n| n.to_str()) { Some(name) => name.to_string(), None => continue, }; // Skip checksum files if filename.ends_with(".sha256") { continue; } // Try to parse as agent binary if let Some((os, arch, version)) = Self::parse_filename(&filename) { // Read checksum from companion file let checksum = self.read_checksum(&path).await.unwrap_or_default(); if checksum.is_empty() { warn!("No checksum found for {}, skipping", filename); continue; } // Get file size let file_size = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0); let download_url = format!("{}/{}", self.base_url.trim_end_matches('/'), filename); let available = AvailableVersion { version, os: os.clone(), arch: arch.clone(), filename: filename.clone(), download_url, checksum_sha256: checksum, file_size, }; let key = format!("{}-{}", os, arch); debug!("Found agent binary: {} (v{})", filename, available.version); versions.entry(key).or_default().push(available); } } // Sort each list by version descending (newest first) for list in versions.values_mut() { list.sort_by(|a, b| b.version.cmp(&a.version)); } let total: usize = versions.values().map(|v| v.len()).sum(); info!("Scanned {} agent binaries across {} platform/arch combinations", total, versions.len()); *self.versions.write().await = versions; Ok(()) } /// Parse a filename to extract OS, architecture, and version /// /// Expected format: gururmm-agent-{os}-{arch}-{version}[.exe] /// Examples: /// - gururmm-agent-linux-amd64-0.2.0 /// - gururmm-agent-windows-amd64-0.2.0.exe fn parse_filename(filename: &str) -> Option<(String, String, Version)> { // Remove .exe extension if present let name = filename.strip_suffix(".exe").unwrap_or(filename); // Split by dashes let parts: Vec<&str> = name.split('-').collect(); // Expected: ["gururmm", "agent", "linux", "amd64", "0", "2", "0"] // or: ["gururmm", "agent", "linux", "amd64", "0.2.0"] if parts.len() < 5 || parts[0] != "gururmm" || parts[1] != "agent" { return None; } let os = parts[2].to_string(); let arch = parts[3].to_string(); // Version could be either: // - A single part with dots: "0.2.0" // - Multiple parts joined: "0", "2", "0" let version_str = if parts.len() == 5 { // Single part with dots parts[4].to_string() } else { // Multiple parts, join with dots parts[4..].join(".") }; let version = Version::parse(&version_str).ok()?; Some((os, arch, version)) } /// Read checksum from companion .sha256 file async fn read_checksum(&self, binary_path: &Path) -> Result { let checksum_path = PathBuf::from(format!("{}.sha256", binary_path.display())); if !checksum_path.exists() { return Err(anyhow::anyhow!("Checksum file not found")); } let content = tokio::fs::read_to_string(&checksum_path).await?; // Checksum file format: " " or just "" let checksum = content .split_whitespace() .next() .ok_or_else(|| anyhow::anyhow!("Empty checksum file"))? .to_lowercase(); // Validate it looks like a SHA256 hash (64 hex chars) if checksum.len() != 64 || !checksum.chars().all(|c| c.is_ascii_hexdigit()) { return Err(anyhow::anyhow!("Invalid checksum format")); } Ok(checksum) } /// Get the latest version available for a given OS/arch pub async fn get_latest_version(&self, os: &str, arch: &str) -> Option { let versions = self.versions.read().await; let key = format!("{}-{}", os, arch); versions.get(&key).and_then(|list| list.first().cloned()) } /// Check if an agent with the given version needs an update /// Returns the available update if one exists pub async fn needs_update( &self, current_version: &str, os: &str, arch: &str, ) -> Option { if !self.auto_update_enabled { return None; } let current = match Version::parse(current_version) { Ok(v) => v, Err(e) => { warn!("Failed to parse current version '{}': {}", current_version, e); return None; } }; let latest = self.get_latest_version(os, arch).await?; if latest.version > current { info!( "Agent needs update: {} -> {} ({}-{})", current, latest.version, os, arch ); Some(latest) } else { None } } /// Get all available versions (for dashboard display) pub async fn get_all_versions(&self) -> HashMap> { self.versions.read().await.clone() } /// Spawn a background task to periodically rescan versions pub fn spawn_scanner(&self, interval_secs: u64) -> tokio::task::JoinHandle<()> { let downloads_dir = self.downloads_dir.clone(); let base_url = self.base_url.clone(); let versions = self.versions.clone(); let auto_update_enabled = self.auto_update_enabled; let update_timeout_secs = self.update_timeout_secs; tokio::spawn(async move { let manager = UpdateManager { downloads_dir, base_url, versions, auto_update_enabled, update_timeout_secs, }; let mut interval = tokio::time::interval(std::time::Duration::from_secs(interval_secs)); loop { interval.tick().await; if let Err(e) = manager.scan_versions().await { error!("Failed to scan versions: {}", e); } } }) } } #[cfg(test)] mod tests { use super::*; #[test] fn test_parse_filename_linux() { let result = UpdateManager::parse_filename("gururmm-agent-linux-amd64-0.2.0"); assert!(result.is_some()); let (os, arch, version) = result.unwrap(); assert_eq!(os, "linux"); assert_eq!(arch, "amd64"); assert_eq!(version, Version::new(0, 2, 0)); } #[test] fn test_parse_filename_windows() { let result = UpdateManager::parse_filename("gururmm-agent-windows-amd64-0.2.0.exe"); assert!(result.is_some()); let (os, arch, version) = result.unwrap(); assert_eq!(os, "windows"); assert_eq!(arch, "amd64"); assert_eq!(version, Version::new(0, 2, 0)); } #[test] fn test_parse_filename_arm64() { let result = UpdateManager::parse_filename("gururmm-agent-linux-arm64-1.0.0"); assert!(result.is_some()); let (os, arch, version) = result.unwrap(); assert_eq!(os, "linux"); assert_eq!(arch, "arm64"); assert_eq!(version, Version::new(1, 0, 0)); } #[test] fn test_parse_filename_invalid() { assert!(UpdateManager::parse_filename("random-file.txt").is_none()); assert!(UpdateManager::parse_filename("gururmm-server-linux-amd64-0.1.0").is_none()); assert!(UpdateManager::parse_filename("gururmm-agent-linux").is_none()); } }