From 2fc4c091dd8f39d2b64255b98918dc4594570300 Mon Sep 17 00:00:00 2001 From: sents Date: Wed, 21 Jan 2026 22:54:40 +0100 Subject: [PATCH 1/3] Applications: Properly parse desktop exec keys and stop shelling out https://specifications.freedesktop.org/desktop-entry-spec/latest/exec-variables.html describes how commands and their arguments are stored in the exec key. As the exec key is a string type as defined in https://specifications.freedesktop.org/desktop-entry-spec/latest/value-types.html escapes code for certain characters have to be respected. After this there are rules which characters necessitate a quoted string and which characters need to be escaped in a quoted string. To use the exec key for executing we then need to unescape these characters and can collect the args in a vector. This enables us to stop shelling out when executing desktop files and instead call `Command` directly with the specified program and arguments. --- plugins/applications/src/lib.rs | 42 +++--- plugins/applications/src/scrubber.rs | 203 +++++++++++++++++++++++++-- 2 files changed, 214 insertions(+), 31 deletions(-) diff --git a/plugins/applications/src/lib.rs b/plugins/applications/src/lib.rs index a4f8f346..343814e5 100644 --- a/plugins/applications/src/lib.rs +++ b/plugins/applications/src/lib.rs @@ -1,7 +1,7 @@ use abi_stable::std_types::{ROption, RString, RVec}; use anyrun_plugin::{anyrun_interface::HandleResult, *}; use fuzzy_matcher::FuzzyMatcher; -use scrubber::DesktopEntry; +use scrubber::{DesktopEntry, lower_exec}; use serde::Deserialize; use std::{env, fs, path::PathBuf, process::Command}; @@ -53,6 +53,9 @@ pub fn handler(selection: Match, state: &State) -> HandleResult { } }) .unwrap(); + let (command, argv) = lower_exec(&entry.exec).unwrap_or_else( + |e| panic!("Unable to parse the exec key `{}`: {}", &entry.exec, e.0) + ); let exec = if let Some(script) = &state.config.preprocess_exec_script { let output = Command::new("sh") @@ -77,13 +80,10 @@ pub fn handler(selection: Match, state: &State) -> HandleResult { if entry.term { match &state.config.terminal { Some(term) => { - if let Err(why) = Command::new("sh") - .arg("-c") - .arg(format!( - "{} {}", - term.command, - term.args.replace("{}", &exec) - )) + if let Err(why) = Command::new(&term.command) + .arg(&term.args) + .arg(command) + .args(argv) .spawn() { eprintln!("[applications] Error running desktop entry: {}", why); @@ -93,23 +93,23 @@ pub fn handler(selection: Match, state: &State) -> HandleResult { let sensible_terminals = &[ Terminal { command: "alacritty".to_string(), - args: "-e {}".to_string(), + args: "-e".to_string(), }, Terminal { command: "foot".to_string(), - args: "-e \"{}\"".to_string(), + args: "-e".to_string(), }, Terminal { command: "kitty".to_string(), - args: "-e \"{}\"".to_string(), + args: "-e".to_string(), }, Terminal { command: "wezterm".to_string(), - args: "-e \"{}\"".to_string(), + args: "-e".to_string(), }, Terminal { command: "wterm".to_string(), - args: "-e \"{}\"".to_string(), + args: "-e".to_string(), }, Terminal { command: "ghostty".to_string(), @@ -122,13 +122,10 @@ pub fn handler(selection: Match, state: &State) -> HandleResult { .output() .is_ok_and(|output| output.status.success()) { - if let Err(why) = Command::new("sh") - .arg("-c") - .arg(format!( - "{} {}", - term.command, - term.args.replace("{}", &exec) - )) + if let Err(why) = Command::new(&term.command) + .arg(&term.args) + .arg(command) + .args(argv) .spawn() { eprintln!("Error running desktop entry: {}", why); @@ -141,9 +138,8 @@ pub fn handler(selection: Match, state: &State) -> HandleResult { } else if let Err(why) = { let current_dir = &env::current_dir().unwrap(); - Command::new("sh") - .arg("-c") - .arg(&exec) + Command::new(command) + .args(argv) .current_dir(match &entry.path { Some(path) if path.exists() => path, _ => current_dir, diff --git a/plugins/applications/src/scrubber.rs b/plugins/applications/src/scrubber.rs index 011b8a34..57a5d6bf 100644 --- a/plugins/applications/src/scrubber.rs +++ b/plugins/applications/src/scrubber.rs @@ -21,6 +21,200 @@ const FIELD_CODE_LIST: &[&str] = &[ "%f", "%F", "%u", "%U", "%d", "%D", "%n", "%N", "%i", "%c", "%k", "%v", "%m", ]; +// See https://specifications.freedesktop.org/desktop-entry-spec/latest/exec-variables.html +const EXEC_ESCAPE_CHARS: &[char] = &['"', '`', '$', '\\']; + +/* +Reserved characters are space (" "), tab, newline, double quote, +single quote ("'"), backslash character ("\"), greater-than sign +(">"), less-than sign ("<"), tilde ("~"), vertical bar ("|"), +ampersand ("&"), semicolon (";"), dollar sign ("$"), asterisk ("*"), +question mark ("?"), hash mark ("#"), parenthesis ("(") and (")") and +backtick character ("`"). +*/ +const EXEC_RESERVED_CHARS: &[char] = &[ + ' ', '\t', '\n', '"', '\'', '\\', '>', '<', '~', '|', '&', ';', '$', '*', '?', '#', '(', ')', + '`', +]; + +// \s, \n, \t, \r, and \\ are valid escapes in Desktop strings +const DESKTOP_STRING_ESCAPES: &[(char, char)] = &[ + ('s', ' '), + ('n', '\n'), + ('t', '\t'), + ('r', '\r'), + ('\\', '\\'), +]; + +fn get_desktop_string_escapes() -> HashMap { + HashMap::from_iter(DESKTOP_STRING_ESCAPES.iter().cloned()) +} + +#[derive(Debug, Clone)] +pub struct ExecKeyError(pub String); + +#[derive(Debug, Clone)] +enum StringEscapeState { + Waiting, + Escape, +} + +fn substitute_escapes(s: &str) -> Result { + use StringEscapeState::*; + + let escapes = get_desktop_string_escapes(); + let mut state = Waiting; + let mut out = Vec::::new(); + for (i, c) in s.chars().enumerate() { + match state { + Waiting => match c { + '\\' => { + state = Escape; + } + _ => { + out.push(c); + } + }, + Escape => match c { + c if escapes.contains_key(&c) => { + out.push(*escapes.get(&c).unwrap()); + state = Waiting; + } + _ => { + return Err(ExecKeyError(format!( + "Escaping invalid character {} at position {}", + c, i + ))) + } + }, + } + } + if let Escape = state { + return Err(ExecKeyError("Dangling escape".to_string())); + } + Ok(out.into_iter().collect()) +} + +#[derive(Debug, Clone)] +enum ExecKeyState { + Waiting, + Word, + Quoting, + Escape, +} + +fn unescape_exec(s: &str) -> Result, ExecKeyError> { + use ExecKeyState::*; + + let mut state = Waiting; + let mut out = Vec::::new(); + let mut buffer = Vec::::new(); + + for (i, c) in s.chars().enumerate() { + match state { + Waiting => { + match c { + '"' => { + state = Quoting; + continue; + } + ' ' => continue, + c if EXEC_RESERVED_CHARS.contains(&c) => return Err(ExecKeyError(format!( + "Starting word with reserved character {} at position {}, consider quoting", + c, i + ))), + _ => { + state = Word; + } + }; + buffer.push(c); + } + Word => match c { + ' ' => { + state = Waiting; + out.push(buffer.iter().collect()); + buffer.clear(); + } + c if EXEC_RESERVED_CHARS.contains(&c) => { + return Err(ExecKeyError(format!( + "Reserved character {} in unquoted word at position {}", + c, i + ))) + } + _ => buffer.push(c), + }, + Quoting => match c { + '"' => { + out.push(buffer.iter().collect()); + buffer.clear(); + state = Waiting; + continue; + } + '\\' => state = Escape, + c if EXEC_ESCAPE_CHARS.contains(&c) => { + return Err(ExecKeyError(format!( + "Unescaped character {} in quoted string at position {}", + c, i + ))); + } + _ => { + buffer.push(c); + } + }, + Escape => match c { + c if EXEC_ESCAPE_CHARS.contains(&c) => { + buffer.push(c); + state = Quoting; + } + _ => { + return Err(ExecKeyError(format!( + "Escaping invalid character {} in quoted string at position {}", + c, i + ))) + } + }, + } + } + match state { + Waiting => {} + Word => { + out.push(buffer.iter().collect()); + buffer.clear(); + } + _ => return Err(ExecKeyError("Invalid state at end of exec key".to_string())), + } + + Ok(out) +} + +/* +1. Substitute general desktop string escapes +2. Unescape EXEC_ESCAPE_CHARS in exec key quoted strings +3. Strip field codes and throw away empty args +*/ +pub(crate) fn lower_exec(s: &str) -> Result<(String, Vec), ExecKeyError> { + let subst = substitute_escapes(s)?; + let argvec = unescape_exec(&subst)?; + if let Some((command, argv)) = argvec.split_first() { + let argv_without_fieldcodes = argv + .to_vec() + .into_iter() + .map(|mut c| { + for field_code in FIELD_CODE_LIST.iter() { + c = c.replace(field_code, ""); + } + c + }) + .filter(|c| { + !c.is_empty() + }) + .collect(); + return Ok((command.clone(), argv_without_fieldcodes)); + } else { + return Err(ExecKeyError("Empty exec key!".to_string())); + } +} + impl DesktopEntry { pub fn localized_name(&self) -> String { self.localized_name @@ -81,14 +275,7 @@ impl DesktopEntry { } { Some(DesktopEntry { - exec: { - let mut exec = map.get("Exec")?.to_string(); - - for field_code in FIELD_CODE_LIST { - exec = exec.replace(field_code, ""); - } - exec - }, + exec: map.get("Exec")?.to_string(), path: map.get("Path").map(PathBuf::from), name: map.get("Name")?.to_string(), localized_name: lang_choices From c28672ba1de32928149807840f2a8cfd69c04a0d Mon Sep 17 00:00:00 2001 From: sents Date: Wed, 21 Jan 2026 22:48:41 +0100 Subject: [PATCH 2/3] Use shell-words to split preprocessed script --- Cargo.lock | 7 +++++ plugins/applications/Cargo.toml | 1 + plugins/applications/src/lib.rs | 55 ++++++++++++++++++++++----------- 3 files changed, 45 insertions(+), 18 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b2251114..f1b2a7db 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -198,6 +198,7 @@ dependencies = [ "fuzzy-matcher", "ron 0.8.1", "serde", + "shell-words", "sublime_fuzzy", ] @@ -2565,6 +2566,12 @@ dependencies = [ "serde", ] +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shlex" version = "1.3.0" diff --git a/plugins/applications/Cargo.toml b/plugins/applications/Cargo.toml index de60c213..4e3ea982 100644 --- a/plugins/applications/Cargo.toml +++ b/plugins/applications/Cargo.toml @@ -14,4 +14,5 @@ anyrun-plugin = { path = "../../anyrun-plugin" } fuzzy-matcher = "0.3.7" ron = "0.8.0" serde = { features = [ "derive" ], version = "1.0.228" } +shell-words = "1.1.1" sublime_fuzzy = "0.7.0" diff --git a/plugins/applications/src/lib.rs b/plugins/applications/src/lib.rs index 343814e5..1e3ab48b 100644 --- a/plugins/applications/src/lib.rs +++ b/plugins/applications/src/lib.rs @@ -4,6 +4,7 @@ use fuzzy_matcher::FuzzyMatcher; use scrubber::{DesktopEntry, lower_exec}; use serde::Deserialize; use std::{env, fs, path::PathBuf, process::Command}; +use shell_words; #[derive(Deserialize)] pub struct Config { @@ -53,28 +54,46 @@ pub fn handler(selection: Match, state: &State) -> HandleResult { } }) .unwrap(); - let (command, argv) = lower_exec(&entry.exec).unwrap_or_else( - |e| panic!("Unable to parse the exec key `{}`: {}", &entry.exec, e.0) - ); - - let exec = if let Some(script) = &state.config.preprocess_exec_script { - let output = Command::new("sh") - .arg("-c") - .arg(format!( - "{} {} {}", - script.display(), - if entry.term { "term" } else { "no-term" }, - &entry.exec - )) + + let (command, argv) = match lower_exec(&entry.exec) { + Ok((command, argv)) => (command, argv), + Err(error) => { + eprintln!("[applications] Unable to parse the exec key `{}`: {}", &entry.exec, error.0); + return HandleResult::Close + } + }; + + let (command, argv) = if let Some(script) = &state.config.preprocess_exec_script { + let output = match Command::new(script.as_os_str()) + .arg(if entry.term { "term" } else { "no-term" }) + .arg(command) + .args(argv) .output() - .unwrap_or_else(|why| { + { + Ok(output) => output, + Err(why) => { eprintln!("[applications] Error running preprocess script: {}", why); - std::process::exit(1); - }); + return HandleResult::Close + } + }; + + let args = match shell_words::split(String::from_utf8_lossy(&output.stdout).trim()) { + Ok(args) => args, + Err(error) => { + eprintln!("[applications] Unable to parse the output of the preprocessing script: {}", error); + return HandleResult::Close + } + }; - String::from_utf8_lossy(&output.stdout).trim().to_string() + let mut it = args.into_iter(); + let Some(command) = it.next() else { + eprintln!("[applications] Empty output of preprocessing script."); + return HandleResult::Close + }; + let argv = it.collect(); + (command, argv) } else { - entry.exec.clone() + (command, argv) }; if entry.term { From a6ae6ce69a3a6dcf75888d5208d0dd135a1c349c Mon Sep 17 00:00:00 2001 From: Finn Krein-Schuch Date: Mon, 26 Jan 2026 01:19:45 +0100 Subject: [PATCH 3/3] Implement handling exec key field codes %i and %c --- plugins/applications/src/lib.rs | 2 +- plugins/applications/src/scrubber.rs | 120 +++++++++++++++++++++------ 2 files changed, 97 insertions(+), 25 deletions(-) diff --git a/plugins/applications/src/lib.rs b/plugins/applications/src/lib.rs index 1e3ab48b..9a3fecf4 100644 --- a/plugins/applications/src/lib.rs +++ b/plugins/applications/src/lib.rs @@ -55,7 +55,7 @@ pub fn handler(selection: Match, state: &State) -> HandleResult { }) .unwrap(); - let (command, argv) = match lower_exec(&entry.exec) { + let (command, argv) = match lower_exec(&entry) { Ok((command, argv)) => (command, argv), Err(error) => { eprintln!("[applications] Unable to parse the exec key `{}`: {}", &entry.exec, error.0); diff --git a/plugins/applications/src/scrubber.rs b/plugins/applications/src/scrubber.rs index 57a5d6bf..df0c5632 100644 --- a/plugins/applications/src/scrubber.rs +++ b/plugins/applications/src/scrubber.rs @@ -17,8 +17,14 @@ pub struct DesktopEntry { pub is_action: bool, } -const FIELD_CODE_LIST: &[&str] = &[ - "%f", "%F", "%u", "%U", "%d", "%D", "%n", "%N", "%i", "%c", "%k", "%v", "%m", +const SUPPORTED_FIELD_CODES: &[char] = &[ 'i', 'c' ]; + +const VALID_FIELD_CODES: &[char] = &[ + 'f', 'F', 'u', 'U', 'd', 'D', 'n', 'N', 'i', 'c', 'k', 'v', 'm', +]; + +const DEPRECATED_FIELD_CODES: &[char] = &[ + 'd', 'D', 'n', 'N' ]; // See https://specifications.freedesktop.org/desktop-entry-spec/latest/exec-variables.html @@ -187,29 +193,100 @@ fn unescape_exec(s: &str) -> Result, ExecKeyError> { Ok(out) } +#[derive(Debug, Clone)] +enum FieldCodeState { + Reading, + Percent +} + +fn get_fieldcode(code: char, entry: &DesktopEntry, arg: &str) -> Result { + let result = match code { + 'c' => entry.localized_name(), + 'i' => { + if arg.len() > 2 { + return Err( + ExecKeyError( + format!( + "Encountered field code %i in argument {} with other other contents, %i must stand alone.", arg) + )) + } + format!("--icon {}", entry.icon.clone()) + }, + c => panic!("Function called with unimplemented field code {}!", c) + }; + Ok(result) +} + +fn expand_exec_fieldcodes(entry: &DesktopEntry, arg: String) -> Result { + use FieldCodeState::*; + + let mut out = String::new(); + let mut state = Reading; + + for c in arg.chars() { + match state { + Reading => { + if c == '%' { + state = Percent; + } else { + out.push(c); + } + } + Percent => { + match c { + '%' => out.push('%'), + c if SUPPORTED_FIELD_CODES.contains(&c) => { + let field_code_content = get_fieldcode(c, &entry, &arg)?; + out.push_str(&field_code_content); + }, + c if VALID_FIELD_CODES.contains(&c) => { + eprintln!( + "Argument {} contains field code %{} which is valid but not implemented and will be stripped.", + &arg, + c + ) + }, + c if DEPRECATED_FIELD_CODES.contains(&c) => { + eprintln!( + "Argument {} contains deprecated field code %{} which will be stripped.", + &arg, + c + ) + }, + _ => { + return Err(ExecKeyError(format!("Argument {} contains unknown field code %{}.", &arg, c))) + } + } + state = Reading; + } + } + } + if matches!(state, Percent) { + return Err(ExecKeyError(format!("Argument {} ends in % which is interpreted as unfinished field code.", &arg))) + }; + return Ok(out) +} + /* 1. Substitute general desktop string escapes 2. Unescape EXEC_ESCAPE_CHARS in exec key quoted strings -3. Strip field codes and throw away empty args +3. Process field codes +4. Throw away empty args */ -pub(crate) fn lower_exec(s: &str) -> Result<(String, Vec), ExecKeyError> { - let subst = substitute_escapes(s)?; +pub(crate) fn lower_exec(entry: &DesktopEntry) -> Result<(String, Vec), ExecKeyError> { + let subst = substitute_escapes(&entry.exec)?; let argvec = unescape_exec(&subst)?; if let Some((command, argv)) = argvec.split_first() { - let argv_without_fieldcodes = argv - .to_vec() + if command.contains('=') { + return Err(ExecKeyError("Executable program must not contain '=' character.".to_string())) + }; + + let argv_fieldcodes = argv .into_iter() - .map(|mut c| { - for field_code in FIELD_CODE_LIST.iter() { - c = c.replace(field_code, ""); - } - c - }) - .filter(|c| { - !c.is_empty() - }) - .collect(); - return Ok((command.clone(), argv_without_fieldcodes)); + .map(|arg| expand_exec_fieldcodes(&entry, arg.clone())) + .collect::,_>>()?; + let argv_stripped = argv_fieldcodes.into_iter().filter(|arg| arg.is_empty()).collect(); + return Ok((command.clone(), argv_stripped)); } else { return Err(ExecKeyError("Empty exec key!".to_string())); } @@ -341,12 +418,7 @@ impl DesktopEntry { ret.push(DesktopEntry { exec: match map.get("Exec") { Some(exec) => { - let mut exec = exec.to_string(); - - for field_code in FIELD_CODE_LIST { - exec = exec.replace(field_code, ""); - } - exec + exec.to_string() } None => continue, },