use std::fs::{self, File}; use std::io::Read; use std::os::unix::fs::PermissionsExt; use std::path::{Path, PathBuf}; use std::time::SystemTime; #[derive(Debug, Clone, PartialEq)] pub enum AppImageType { Type1, Type2, } impl AppImageType { pub fn as_i32(&self) -> i32 { match self { Self::Type1 => 1, Self::Type2 => 2, } } } #[derive(Debug, Clone)] pub struct DiscoveredAppImage { pub path: PathBuf, pub filename: String, pub appimage_type: AppImageType, pub size_bytes: u64, pub modified_time: Option, pub is_executable: bool, } /// Expand ~ to home directory. pub fn expand_tilde(path: &str) -> PathBuf { if let Some(rest) = path.strip_prefix("~/") { if let Some(home) = dirs::home_dir() { return home.join(rest); } } if path == "~" { if let Some(home) = dirs::home_dir() { return home; } } PathBuf::from(path) } /// Check a single file for AppImage magic bytes. /// ELF magic at offset 0: 0x7F 'E' 'L' 'F' /// AppImage Type 2 at offset 8: 'A' 'I' 0x02 /// AppImage Type 1 at offset 8: 'A' 'I' 0x01 pub fn detect_appimage(path: &Path) -> Option { let mut file = File::open(path).ok()?; let mut header = [0u8; 16]; file.read_exact(&mut header).ok()?; // Check ELF magic if header[0..4] != [0x7F, 0x45, 0x4C, 0x46] { return None; } // Check AppImage magic at offset 8 if header[8] == 0x41 && header[9] == 0x49 { match header[10] { 0x02 => return Some(AppImageType::Type2), 0x01 => return Some(AppImageType::Type1), _ => {} } } None } /// Scan a single directory for AppImage files (non-recursive). fn scan_directory(dir: &Path) -> Vec { let mut results = Vec::new(); let entries = match fs::read_dir(dir) { Ok(entries) => entries, Err(e) => { log::warn!("Cannot read directory {}: {}", dir.display(), e); return results; } }; for entry in entries.flatten() { let path = entry.path(); // Skip directories and symlinks to directories if path.is_dir() { continue; } // Skip very small files (AppImages are at least a few KB) let metadata = match fs::metadata(&path) { Ok(m) => m, Err(_) => continue, }; if metadata.len() < 4096 { continue; } // Check for AppImage magic bytes if let Some(appimage_type) = detect_appimage(&path) { let filename = path .file_name() .map(|n| n.to_string_lossy().into_owned()) .unwrap_or_default(); let mut is_executable = metadata.permissions().mode() & 0o111 != 0; if !is_executable { let perms = std::fs::Permissions::from_mode(0o755); if std::fs::set_permissions(&path, perms).is_ok() { is_executable = true; log::info!("Auto-fixed executable permission: {}", path.display()); } } let modified_time = metadata.modified().ok(); results.push(DiscoveredAppImage { path, filename, appimage_type, size_bytes: metadata.len(), modified_time, is_executable, }); } } results } /// Scan all configured directories for AppImages. /// Directories are expanded (~ -> home) and deduplicated. pub fn scan_directories(dirs: &[String]) -> Vec { let mut results = Vec::new(); let mut seen_paths = std::collections::HashSet::new(); for dir_str in dirs { let dir = expand_tilde(dir_str); if !dir.exists() { log::info!("Scan directory does not exist: {}", dir.display()); continue; } if !dir.is_dir() { log::warn!("Scan path is not a directory: {}", dir.display()); continue; } for discovered in scan_directory(&dir) { // Deduplicate by canonical path let canonical = discovered.path.canonicalize() .unwrap_or_else(|_| discovered.path.clone()); if seen_paths.insert(canonical) { results.push(discovered); } } } results } /// Compute the SHA-256 hash of a file, returned as a lowercase hex string. pub fn compute_sha256(path: &Path) -> std::io::Result { use sha2::{Digest, Sha256}; let mut file = File::open(path)?; let mut hasher = Sha256::new(); std::io::copy(&mut file, &mut hasher)?; Ok(format!("{:x}", hasher.finalize())) } #[cfg(test)] mod tests { use super::*; use std::io::Write; fn create_fake_appimage(dir: &Path, name: &str, appimage_type: u8) -> PathBuf { let path = dir.join(name); let mut f = File::create(&path).unwrap(); // ELF magic f.write_all(&[0x7F, 0x45, 0x4C, 0x46]).unwrap(); // ELF class, data, version, OS/ABI (padding to offset 8) f.write_all(&[0x02, 0x01, 0x01, 0x00]).unwrap(); // AppImage magic at offset 8 f.write_all(&[0x41, 0x49, appimage_type]).unwrap(); // Pad to make it bigger than 4096 bytes f.write_all(&vec![0u8; 8192]).unwrap(); // Make executable #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).unwrap(); } path } #[test] fn test_detect_type2() { let dir = tempfile::tempdir().unwrap(); let path = create_fake_appimage(dir.path(), "test.AppImage", 0x02); assert_eq!(detect_appimage(&path), Some(AppImageType::Type2)); } #[test] fn test_detect_type1() { let dir = tempfile::tempdir().unwrap(); let path = create_fake_appimage(dir.path(), "test.AppImage", 0x01); assert_eq!(detect_appimage(&path), Some(AppImageType::Type1)); } #[test] fn test_detect_not_appimage() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("not_appimage"); let mut f = File::create(&path).unwrap(); f.write_all(&[0x7F, 0x45, 0x4C, 0x46, 0x02, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00]).unwrap(); f.write_all(&vec![0u8; 8192]).unwrap(); assert_eq!(detect_appimage(&path), None); } #[test] fn test_detect_non_elf() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("text.txt"); let mut f = File::create(&path).unwrap(); f.write_all(b"Hello world, this is not an ELF file at all").unwrap(); f.write_all(&vec![0u8; 8192]).unwrap(); assert_eq!(detect_appimage(&path), None); } #[test] fn test_scan_directory() { let dir = tempfile::tempdir().unwrap(); create_fake_appimage(dir.path(), "app1.AppImage", 0x02); create_fake_appimage(dir.path(), "app2.AppImage", 0x02); // Create a non-AppImage file let non_ai = dir.path().join("readme.txt"); fs::write(&non_ai, &vec![0u8; 8192]).unwrap(); let results = scan_directory(dir.path()); assert_eq!(results.len(), 2); assert!(results.iter().all(|r| r.appimage_type == AppImageType::Type2)); } #[test] fn test_expand_tilde() { let expanded = expand_tilde("~/Applications"); assert!(!expanded.to_string_lossy().starts_with('~')); assert!(expanded.to_string_lossy().ends_with("Applications")); } }