Files
driftwood/src/core/fuse.rs
lashman 65a1ea78fe Stop executing AppImages during analysis, add screenshot lightbox and favicons
- fuse.rs: Replace Command::new(appimage_path) with 256KB binary scan for
  runtime detection - prevents apps like Affinity from launching on tile click
- fuse.rs: Read only 12 bytes for Type 2 magic check instead of entire file
- security.rs: Use find_squashfs_offset_for() instead of executing AppImages
  with --appimage-offset flag
- updater.rs: Read only first 1MB for update info instead of entire file
- detail_view.rs: Click screenshots to open in lightbox dialog
- detail_view.rs: Fetch favicons from Google favicon service for link rows
2026-02-27 18:58:12 +02:00

357 lines
11 KiB
Rust

use std::path::Path;
use std::process::Command;
#[derive(Debug, Clone, PartialEq)]
pub enum FuseStatus {
/// libfuse2 available, fusermount present, /dev/fuse exists - fully working
FullyFunctional,
/// Only libfuse3 installed - most AppImages won't mount natively
Fuse3Only,
/// fusermount binary not found
NoFusermount,
/// /dev/fuse device not present (container or WSL)
NoDevFuse,
/// libfuse2 not installed
MissingLibfuse2,
}
impl FuseStatus {
pub fn as_str(&self) -> &'static str {
match self {
Self::FullyFunctional => "fully_functional",
Self::Fuse3Only => "fuse3_only",
Self::NoFusermount => "no_fusermount",
Self::NoDevFuse => "no_dev_fuse",
Self::MissingLibfuse2 => "missing_libfuse2",
}
}
pub fn from_str(s: &str) -> Self {
match s {
"fully_functional" => Self::FullyFunctional,
"fuse3_only" => Self::Fuse3Only,
"no_fusermount" => Self::NoFusermount,
"no_dev_fuse" => Self::NoDevFuse,
_ => Self::MissingLibfuse2,
}
}
pub fn label(&self) -> &'static str {
match self {
Self::FullyFunctional => "OK",
Self::Fuse3Only => "FUSE3 only",
Self::NoFusermount => "No fusermount",
Self::NoDevFuse => "No /dev/fuse",
Self::MissingLibfuse2 => "No libfuse2",
}
}
pub fn badge_class(&self) -> &'static str {
match self {
Self::FullyFunctional => "success",
Self::Fuse3Only => "warning",
Self::NoFusermount | Self::NoDevFuse | Self::MissingLibfuse2 => "error",
}
}
pub fn is_functional(&self) -> bool {
matches!(self, Self::FullyFunctional)
}
}
#[derive(Debug, Clone)]
pub struct FuseSystemInfo {
pub status: FuseStatus,
pub has_libfuse2: bool,
pub has_libfuse3: bool,
pub has_fusermount: bool,
pub fusermount_path: Option<String>,
pub has_dev_fuse: bool,
pub install_hint: Option<String>,
}
/// Detect the system FUSE status by checking for libraries, binaries, and device nodes.
pub fn detect_system_fuse() -> FuseSystemInfo {
let has_libfuse2 = check_library("libfuse.so.2");
let has_libfuse3 = check_library("libfuse3.so.3");
let fusermount_path = find_fusermount();
let has_fusermount = fusermount_path.is_some();
let has_dev_fuse = Path::new("/dev/fuse").exists();
let status = if has_libfuse2 && has_fusermount && has_dev_fuse {
FuseStatus::FullyFunctional
} else if !has_dev_fuse {
FuseStatus::NoDevFuse
} else if !has_fusermount {
FuseStatus::NoFusermount
} else if has_libfuse3 && !has_libfuse2 {
FuseStatus::Fuse3Only
} else {
FuseStatus::MissingLibfuse2
};
let install_hint = if status.is_functional() {
None
} else {
Some(get_install_hint())
};
FuseSystemInfo {
status,
has_libfuse2,
has_libfuse3,
has_fusermount,
fusermount_path,
has_dev_fuse,
install_hint,
}
}
/// Per-AppImage FUSE launch status
#[derive(Debug, Clone, PartialEq)]
pub enum AppImageFuseStatus {
/// Will mount natively via FUSE
NativeFuse,
/// Uses new type2-runtime with static FUSE
StaticRuntime,
/// Will use extract-and-run fallback (slower startup)
ExtractAndRun,
/// Cannot launch at all
CannotLaunch,
}
impl AppImageFuseStatus {
pub fn as_str(&self) -> &'static str {
match self {
Self::NativeFuse => "native_fuse",
Self::StaticRuntime => "static_runtime",
Self::ExtractAndRun => "extract_and_run",
Self::CannotLaunch => "cannot_launch",
}
}
pub fn label(&self) -> &'static str {
match self {
Self::NativeFuse => "Native FUSE",
Self::StaticRuntime => "Static runtime",
Self::ExtractAndRun => "Extract & Run",
Self::CannotLaunch => "Cannot launch",
}
}
pub fn badge_class(&self) -> &'static str {
match self {
Self::NativeFuse | Self::StaticRuntime => "success",
Self::ExtractAndRun => "warning",
Self::CannotLaunch => "error",
}
}
}
/// Determine launch status for a specific AppImage given system FUSE state.
pub fn determine_app_fuse_status(
system: &FuseSystemInfo,
appimage_path: &Path,
) -> AppImageFuseStatus {
// Check if the AppImage uses the new static runtime
if has_static_runtime(appimage_path) {
return AppImageFuseStatus::StaticRuntime;
}
if system.status.is_functional() {
return AppImageFuseStatus::NativeFuse;
}
// FUSE not fully functional - check if extract-and-run works
if supports_extract_and_run(appimage_path) {
AppImageFuseStatus::ExtractAndRun
} else {
AppImageFuseStatus::CannotLaunch
}
}
/// Check if the AppImage uses the new type2-runtime with statically linked FUSE.
/// Scans the first 256KB of the binary for runtime signatures instead of executing
/// the AppImage (which can hang for apps with custom AppRun scripts like Affinity).
fn has_static_runtime(appimage_path: &Path) -> bool {
use std::io::Read;
let mut file = match std::fs::File::open(appimage_path) {
Ok(f) => f,
Err(_) => return false,
};
// The runtime signature is in the ELF binary header area, well within first 256KB
let mut buf = vec![0u8; 256 * 1024];
let n = match file.read(&mut buf) {
Ok(n) => n,
Err(_) => return false,
};
let data = &buf[..n];
let haystack = String::from_utf8_lossy(data).to_lowercase();
haystack.contains("type2-runtime")
|| haystack.contains("libfuse3")
}
/// Check if --appimage-extract-and-run is supported.
fn supports_extract_and_run(appimage_path: &Path) -> bool {
// Check for AppImage Type 2 magic at offset 8 - only need to read 12 bytes
use std::io::Read;
let mut file = match std::fs::File::open(appimage_path) {
Ok(f) => f,
Err(_) => return false,
};
let mut header = [0u8; 12];
if file.read_exact(&mut header).is_ok() {
return header[8] == 0x41 && header[9] == 0x49 && header[10] == 0x02;
}
false
}
/// Check if a shared library is available on the system via ldconfig.
fn check_library(soname: &str) -> bool {
let output = Command::new("ldconfig")
.arg("-p")
.output();
if let Ok(output) = output {
let stdout = String::from_utf8_lossy(&output.stdout);
return stdout.contains(soname);
}
false
}
/// Find fusermount or fusermount3 binary.
fn find_fusermount() -> Option<String> {
for name in &["fusermount", "fusermount3"] {
let output = Command::new("which")
.arg(name)
.output();
if let Ok(output) = output {
if output.status.success() {
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !path.is_empty() {
return Some(path);
}
}
}
}
None
}
/// Detect distro and return the appropriate libfuse2 install command.
fn get_install_hint() -> String {
if let Ok(content) = std::fs::read_to_string("/etc/os-release") {
let id = extract_os_field(&content, "ID");
let version_id = extract_os_field(&content, "VERSION_ID");
let id_like = extract_os_field(&content, "ID_LIKE");
return match id.as_deref() {
Some("ubuntu") => {
let ver: f64 = version_id
.as_deref()
.and_then(|v| v.parse().ok())
.unwrap_or(0.0);
if ver >= 24.04 {
"sudo apt install libfuse2t64".to_string()
} else {
"sudo apt install libfuse2".to_string()
}
}
Some("debian") => "sudo apt install libfuse2".to_string(),
Some("fedora") => "sudo dnf install fuse-libs".to_string(),
Some("arch") | Some("manjaro") | Some("endeavouros") => {
"sudo pacman -S fuse2".to_string()
}
Some("opensuse-tumbleweed") | Some("opensuse-leap") => {
"sudo zypper install libfuse2".to_string()
}
_ => {
// Check ID_LIKE for derivatives
if let Some(like) = id_like.as_deref() {
if like.contains("ubuntu") || like.contains("debian") {
return "sudo apt install libfuse2".to_string();
}
if like.contains("fedora") {
return "sudo dnf install fuse-libs".to_string();
}
if like.contains("arch") {
return "sudo pacman -S fuse2".to_string();
}
if like.contains("suse") {
return "sudo zypper install libfuse2".to_string();
}
}
"Install libfuse2 using your distribution's package manager".to_string()
}
};
}
"Install libfuse2 using your distribution's package manager".to_string()
}
fn extract_os_field(content: &str, key: &str) -> Option<String> {
for line in content.lines() {
if let Some(rest) = line.strip_prefix(&format!("{}=", key)) {
return Some(rest.trim_matches('"').to_string());
}
}
None
}
/// Check if AppImageLauncher is installed (known conflicts with new runtime).
pub fn detect_appimagelauncher() -> Option<String> {
let output = Command::new("dpkg")
.args(["-s", "appimagelauncher"])
.output();
if let Ok(output) = output {
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
for line in stdout.lines() {
if let Some(ver) = line.strip_prefix("Version: ") {
return Some(ver.trim().to_string());
}
}
return Some("unknown".to_string());
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_fuse_status_roundtrip() {
let statuses = [
FuseStatus::FullyFunctional,
FuseStatus::Fuse3Only,
FuseStatus::NoFusermount,
FuseStatus::NoDevFuse,
FuseStatus::MissingLibfuse2,
];
for status in &statuses {
assert_eq!(&FuseStatus::from_str(status.as_str()), status);
}
}
#[test]
fn test_extract_os_field() {
let content = r#"NAME="Ubuntu"
VERSION_ID="24.04"
ID=ubuntu
ID_LIKE=debian
"#;
assert_eq!(extract_os_field(content, "ID"), Some("ubuntu".to_string()));
assert_eq!(extract_os_field(content, "VERSION_ID"), Some("24.04".to_string()));
assert_eq!(extract_os_field(content, "ID_LIKE"), Some("debian".to_string()));
assert_eq!(extract_os_field(content, "MISSING"), None);
}
#[test]
fn test_fuse_status_badges() {
assert_eq!(FuseStatus::FullyFunctional.badge_class(), "success");
assert_eq!(FuseStatus::Fuse3Only.badge_class(), "warning");
assert_eq!(FuseStatus::MissingLibfuse2.badge_class(), "error");
}
}