use serde::Serialize; use std::{ collections::{BTreeMap, HashMap}, fs, io::Read, os::unix::fs::{MetadataExt, PermissionsExt}, path::{Path, PathBuf}, }; #[derive(Clone, Serialize)] pub struct ApplicationInfo { pub name: String, pub executable: String, pub running: bool, pub pid: Option, } pub fn normalize(path: &str) -> Result { if path.len() > 4096 || !Path::new(path).is_absolute() || path.chars().any(char::is_control) { return Err("Choose an absolute executable path".into()); } let p = fs::canonicalize(path).map_err(|_| "Application executable was not found")?; let m = fs::metadata(&p).map_err(|_| "Cannot read application executable")?; if !m.is_file() || m.permissions().mode() & 0o111 == 0 { return Err("Choose an executable file".into()); } let mut magic = [0; 4]; fs::File::open(&p) .and_then(|mut f| f.read_exact(&mut magic)) .map_err(|_| "Cannot read application executable")?; if magic != *b"\x7fELF" { return Err("Choose the real application binary, not a launcher script".into()); } let s = p.to_str().ok_or("Application path must be valid UTF-8")?; if s.chars().any(char::is_control) { return Err("Invalid executable path".into()); } Ok(s.into()) } fn desktop_dirs() -> Vec { let mut dirs = std::env::var_os("XDG_DATA_DIRS") .map(|v| std::env::split_paths(&v).collect::>()) .unwrap_or_else(|| vec!["/usr/local/share".into(), "/usr/share".into()]); if let Some(home) = std::env::var_os("HOME") { dirs.push(PathBuf::from(home).join(".local/share")); } if let Some(home) = std::env::var_os("XDG_DATA_HOME") { if Path::new(&home).is_absolute() { dirs.push(home.into()); } } dirs.into_iter().map(|p| p.join("applications")).collect() } // Tokenize Desktop Entry Exec without ever executing it. Field codes are never evaluated. fn exec_token(exec: &str) -> Option { let mut out = String::new(); let mut quoted = false; let mut escaped = false; for c in exec.trim().chars() { if escaped { out.push(c); escaped = false; } else if c == '\\' { escaped = true; } else if c == '"' { quoted = !quoted; } else if c.is_whitespace() && !quoted { break; } else { out.push(c); } } if out.is_empty() || quoted || escaped { None } else { Some(out) } } fn resolve_program(token: &str) -> Option { if token.starts_with('/') { return normalize(token).ok(); } std::env::var_os("PATH").and_then(|paths| { std::env::split_paths(&paths) .filter(|p| p.is_absolute()) .find_map(|dir| normalize(dir.join(token).to_str()?).ok()) }) } struct DesktopApp { name: String, path: Option, keys: Vec, } fn catalog() -> Vec { let mut result = vec![]; for dir in desktop_dirs() { let Ok(entries) = fs::read_dir(dir) else { continue; }; for entry in entries.flatten().take(4096) { let path = entry.path(); if path.extension().and_then(|s| s.to_str()) != Some("desktop") { continue; } if entry.metadata().map(|m| m.len() > 128_000).unwrap_or(true) { continue; } let Ok(text) = fs::read_to_string(&path) else { continue; }; let mut fields = HashMap::new(); let mut main = false; for line in text.lines() { if line.starts_with('[') { main = line == "[Desktop Entry]"; continue; } if main { if let Some((k, v)) = line.split_once('=') { fields.insert(k, v); } } } if fields.get("Type") != Some(&"Application") || fields.get("Hidden") == Some(&"true") || fields.get("NoDisplay") == Some(&"true") { continue; } let Some(name) = fields .get("Name") .filter(|s| s.len() <= 128 && !s.chars().any(char::is_control)) else { continue; }; let Some(token) = fields.get("Exec").and_then(|s| exec_token(s)) else { continue; }; let mut keys = vec![]; let mut is_launcher = true; if let Some(stem) = Path::new(&token).file_name().and_then(|s| s.to_str()) { // Generic launchers must never group unrelated apps into a single routing rule. if ![ "env", "sh", "bash", "flatpak", "snap", "python", "python3", "node", "electron", "java", ] .contains(&stem) { keys.push(stem.to_lowercase()); is_launcher = false; } } if let Some(wm) = fields.get("StartupWMClass") { keys.push(wm.to_lowercase()); } if keys.is_empty() { continue; } result.push(DesktopApp { name: (*name).into(), path: if is_launcher { None } else { resolve_program(&token) }, keys, }); } } result } pub fn list_installed() -> Vec { let mut by_path = BTreeMap::new(); for app in catalog() { if let Some(path) = app.path { by_path.entry(path.clone()).or_insert(ApplicationInfo { name: app.name, executable: path, running: false, pid: None, }); } } sorted(by_path.into_values().collect()) } pub fn list_running() -> Vec { let apps = catalog(); let uid = unsafe { libc::geteuid() }; let mut by_path = BTreeMap::new(); let Ok(entries) = fs::read_dir("/proc") else { return vec![]; }; for e in entries.flatten() { let Some(pid) = e.file_name().to_str().and_then(|s| s.parse::().ok()) else { continue; }; if e.metadata().map(|m| m.uid() != uid).unwrap_or(true) { continue; } let Ok(exe) = fs::read_link(e.path().join("exe")) else { continue; }; let Some(executable) = exe.to_str() else { continue; }; if executable.ends_with(" (deleted)") { continue; } let basename = exe .file_name() .unwrap_or_default() .to_string_lossy() .to_lowercase(); let app = apps .iter() .find(|a| a.path.as_deref() == Some(executable)) .or_else(|| apps.iter().find(|a| a.keys.contains(&basename))); let Some(app) = app else { continue }; by_path .entry(executable.to_owned()) .or_insert(ApplicationInfo { name: app.name.clone(), executable: executable.into(), running: true, pid: Some(pid), }); } sorted(by_path.into_values().collect()) } fn sorted(mut apps: Vec) -> Vec { apps.sort_by_cached_key(|a| a.name.to_lowercase()); apps } pub fn describe(path: &str) -> Result { let path = normalize(path)?; if let Some(app) = list_running() .into_iter() .chain(list_installed()) .find(|a| a.executable == path) { return Ok(app); } let name = Path::new(&path) .file_name() .unwrap_or_default() .to_string_lossy() .into_owned(); Ok(ApplicationInfo { name, executable: path, running: false, pid: None, }) } #[cfg(test)] mod tests { use super::*; #[test] fn quoted_desktop_exec() { assert_eq!( exec_token("\"/opt/My App/app\" %U"), Some("/opt/My App/app".into()) ); assert!(exec_token("\"unclosed").is_none()); } #[test] fn path_validation() { assert!(normalize("relative").is_err()); assert!(normalize("/etc/passwd").is_err()); assert!(normalize("/tmp/x\n").is_err()); assert!(normalize("/usr/bin/true").is_ok()); } #[test] fn scripts_are_not_routing_identities() { let d = tempfile::tempdir().unwrap(); let p = d.path().join("launcher"); fs::write(&p, "#!/bin/sh\nexec true\n").unwrap(); fs::set_permissions(&p, fs::Permissions::from_mode(0o755)).unwrap(); assert!(normalize(p.to_str().unwrap()).is_err()); } }