255 lines
7.6 KiB
Rust
255 lines
7.6 KiB
Rust
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<SystemTime>,
|
|
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<AppImageType> {
|
|
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<DiscoveredAppImage> {
|
|
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<DiscoveredAppImage> {
|
|
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<String> {
|
|
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"));
|
|
}
|
|
}
|