292 lines
8.9 KiB
Rust
292 lines
8.9 KiB
Rust
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<u32>,
|
|
}
|
|
|
|
pub fn normalize(path: &str) -> Result<String, String> {
|
|
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<PathBuf> {
|
|
let mut dirs = std::env::var_os("XDG_DATA_DIRS")
|
|
.map(|v| std::env::split_paths(&v).collect::<Vec<_>>())
|
|
.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<String> {
|
|
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<String> {
|
|
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<String>,
|
|
keys: Vec<String>,
|
|
}
|
|
|
|
fn catalog() -> Vec<DesktopApp> {
|
|
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<ApplicationInfo> {
|
|
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<ApplicationInfo> {
|
|
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::<u32>().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<ApplicationInfo>) -> Vec<ApplicationInfo> {
|
|
apps.sort_by_cached_key(|a| a.name.to_lowercase());
|
|
apps
|
|
}
|
|
|
|
pub fn describe(path: &str) -> Result<ApplicationInfo, String> {
|
|
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());
|
|
}
|
|
}
|