Release MiniVLESS 0.1.0 for Linux
This commit is contained in:
@@ -0,0 +1,291 @@
|
||||
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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user