Files
driftwood/src/core/discovery.rs

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"));
}
}