enBoxer/src/hypr.rs
en 20f0160799 Shell-quote bind_command args (Hyprland exec_cmd injection)
fix(hypr): Grok-flagged security finding: shell_single was applied to
bin and sock but args was interpolated raw. A profile hotkey containing
shell metacharacters could become a Hyprland exec_cmd injection.

- src/hypr.rs: bind_command now wraps args in shell_single
- New tests:
  - bind_command_quotes_args_with_metacharacters
  - shell_single_handles_inner_quote
- Doc comment updated to name the threat and what gets escaped

93+/0 cargo test; clippy clean.
2026-09-16 05:44:52 +02:00

345 lines
9.5 KiB
Rust

//! Hyprland 0.56 IPC: JSON queries + Lua eval/dispatch. No game injection.
use crate::hotkey::ParsedHotkey;
use anyhow::{bail, Context, Result};
use serde::Deserialize;
use std::path::PathBuf;
use std::process::Stdio;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::net::UnixStream;
use tokio::process::Command;
#[derive(Debug, Clone, Deserialize)]
pub struct Client {
pub address: String,
pub class: String,
pub title: String,
pub pid: i32,
pub at: [i32; 2],
pub size: [i32; 2],
#[serde(default)]
pub mapped: bool,
#[serde(default)]
pub hidden: bool,
#[serde(default)]
pub xwayland: bool,
#[serde(rename = "focusHistoryID", default)]
pub focus_history_id: i64,
}
impl Client {
pub fn address_selector(&self) -> String {
let a = self.address.trim();
if a.starts_with("address:") {
a.to_string()
} else {
format!("address:{a}")
}
}
}
pub async fn clients() -> Result<Vec<Client>> {
let raw = hyprctl(["-j", "clients"]).await?;
serde_json::from_str(&raw).context("parse hyprctl clients")
}
pub async fn cursor_pos() -> Result<(i32, i32)> {
let raw = hyprctl(["-j", "cursorpos"]).await?;
#[derive(Deserialize)]
struct P {
x: i32,
y: i32,
}
let p: P = serde_json::from_str(&raw).context("cursorpos")?;
Ok((p.x, p.y))
}
pub async fn hyprctl<I, S>(args: I) -> Result<String>
where
I: IntoIterator<Item = S>,
S: AsRef<std::ffi::OsStr>,
{
let out = Command::new("hyprctl")
.args(args)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.await
.context("spawn hyprctl")?;
if !out.status.success() {
bail!("hyprctl failed: {}", String::from_utf8_lossy(&out.stderr));
}
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}
pub async fn eval_lua(code: &str) -> Result<String> {
hyprctl(["eval", code]).await
}
pub async fn dispatch_lua(expr: &str) -> Result<String> {
hyprctl(["dispatch", expr]).await
}
pub fn send_shortcut_expr(window: &str, parsed: &ParsedHotkey) -> String {
let mods = parsed.hypr_mods();
format!(
"hl.dsp.send_shortcut({{ window = {window:?}, mods = {mods:?}, key = {:?} }})",
parsed.hypr_key()
)
}
pub fn send_key_state_expr(window: &str, parsed: &ParsedHotkey, state: &str) -> String {
let mods = parsed.hypr_mods();
format!(
"hl.dsp.send_key_state({{ window = {window:?}, mods = {mods:?}, key = {:?}, state = {state:?} }})",
parsed.hypr_key()
)
}
pub async fn send_key(window: &str, combo: &str, state: Option<&str>) -> Result<()> {
let parsed = crate::hotkey::parse(combo)?;
let expr = match state {
Some(s) => send_key_state_expr(window, &parsed, s),
None => send_shortcut_expr(window, &parsed),
};
dispatch_lua(&expr).await?;
Ok(())
}
pub async fn focus_window(selector: &str) -> Result<()> {
dispatch_lua(&format!("hl.dsp.focus({{ window = {selector:?} }})")).await?;
Ok(())
}
/// XWayland (Wine) accepts keys while unfocused. Native Wayland often does not,
/// so we briefly focus, send, then restore.
pub async fn deliver_key(client: &Client, combo: &str, state: Option<&str>) -> Result<()> {
let sel = client.address_selector();
if client.xwayland {
return send_key(&sel, combo, state).await;
}
let prev = active_window().await.ok().flatten();
let _ = focus_window(&sel).await;
send_key(&sel, combo, state).await?;
if let Some(p) = prev {
if p.address != client.address {
let _ = focus_window(&p.address_selector()).await;
}
}
Ok(())
}
pub async fn deliver_click(client: &Client, button: u32) -> Result<()> {
let sel = client.address_selector();
if client.xwayland {
return send_mouse_click(&sel, button).await;
}
let prev = active_window().await.ok().flatten();
let _ = focus_window(&sel).await;
send_mouse_click(&sel, button).await?;
if let Some(p) = prev {
if p.address != client.address {
let _ = focus_window(&p.address_selector()).await;
}
}
Ok(())
}
pub async fn send_mouse_click(window: &str, button: u32) -> Result<()> {
// 272 = BTN_LEFT, 273 = BTN_RIGHT
let expr = format!(
"hl.dsp.send_shortcut({{ window = {window:?}, mods = \"\", key = \"mouse:{button}\" }})"
);
dispatch_lua(&expr).await?;
Ok(())
}
pub async fn move_cursor(x: i32, y: i32) -> Result<()> {
dispatch_lua(&format!("hl.dsp.cursor.move({{ x = {x}, y = {y} }})")).await?;
Ok(())
}
pub async fn move_resize_window(selector: &str, x: i32, y: i32, w: i32, h: i32) -> Result<()> {
dispatch_lua(&format!(
"hl.dsp.window.move({{ window = {selector:?}, x = {x}, y = {y}, relative = false }})"
))
.await?;
dispatch_lua(&format!(
"hl.dsp.window.resize({{ window = {selector:?}, x = {w}, y = {h}, relative = false }})"
))
.await?;
Ok(())
}
const CLEAR_BINDS: &str = r#"
_G.enboxer = _G.enboxer or { binds = {} }
for _, b in ipairs(_G.enboxer.binds) do
pcall(function() b:remove() end)
end
_G.enboxer.binds = {}
"#;
/// Install or replace the enBoxer bind table in Hyprland's Lua VM.
pub async fn replace_binds(binds: &[BindSpec], ipc: &str) -> Result<()> {
let mut body = String::from(CLEAR_BINDS);
for b in binds {
let opts = match (b.non_consuming, b.release) {
(true, true) => "{ description = \"enboxer\", non_consuming = true, release = true }",
(true, false) => "{ description = \"enboxer\", non_consuming = true }",
(false, true) => "{ description = \"enboxer\", release = true }",
(false, false) => "{ description = \"enboxer\" }",
};
let cmd = bind_command(&b.ipc_bin, ipc, &b.ipc_args);
body.push_str(&format!(
r#"
do
local b = hl.bind({bind:?}, function()
hl.dispatch(hl.dsp.exec_cmd({cmd:?}))
end, {opts})
table.insert(_G.enboxer.binds, b)
end
"#,
bind = b.bind,
cmd = cmd,
opts = opts,
));
}
eval_lua(&body).await?;
Ok(())
}
pub async fn clear_binds() -> Result<()> {
eval_lua(CLEAR_BINDS).await?;
Ok(())
}
#[derive(Debug, Clone)]
pub struct BindSpec {
pub bind: String,
pub ipc_bin: String,
pub ipc_args: String,
pub non_consuming: bool,
pub release: bool,
}
fn shell_single(s: &str) -> String {
format!("'{}'", s.replace('\'', r#"'"'"'"#))
}
/// Hyprland exec_cmd string. `args` is the IPC line after the binary, e.g. `hotkey Alt+G`.
/// `bin`, `sock`, and `args` are all single-quoted so a map hotkey containing
/// shell metacharacters (`;`, backticks, `$()`, quotes, spaces) cannot inject
/// into the compositor's `exec_cmd`.
pub fn bind_command(bin: &str, sock: &str, args: &str) -> String {
format!(
"{} ipc --sock {} {}",
shell_single(bin),
shell_single(sock),
shell_single(args)
)
}
pub async fn notify(text: &str) -> Result<()> {
// 1 = info icon; 2500 ms
let _ = hyprctl(["notify", "1", "2500", "rgb(88aaff)", text]).await;
Ok(())
}
pub async fn apply_borderless_rules(class_re: &str) -> Result<()> {
if class_re.is_empty() {
return Ok(());
}
let expr = format!(
r#"
hl.window_rule({{
match = {{ class = {class_re:?} }},
rounding = 0,
border_size = 0,
}})
"#
);
eval_lua(&expr).await?;
Ok(())
}
pub async fn apply_vfx_window_rules() -> Result<()> {
eval_lua(
r#"
hl.window_rule({
match = { class = "enboxer-vfx" },
float = true,
pin = true,
no_anim = true,
rounding = 0,
border_size = 1,
})
"#,
)
.await?;
Ok(())
}
pub async fn active_window() -> Result<Option<Client>> {
let raw = hyprctl(["-j", "activewindow"]).await?;
if raw.trim() == "{}" || raw.trim().is_empty() {
return Ok(None);
}
Ok(serde_json::from_str(&raw).ok())
}
pub fn socket2_path() -> Result<PathBuf> {
let sig = std::env::var("HYPRLAND_INSTANCE_SIGNATURE")
.context("HYPRLAND_INSTANCE_SIGNATURE not set — not in a Hyprland session")?;
let runtime = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".into());
Ok(PathBuf::from(runtime)
.join("hypr")
.join(sig)
.join(".socket2.sock"))
}
pub async fn listen_events<F, Fut>(mut on_line: F) -> Result<()>
where
F: FnMut(String) -> Fut,
Fut: std::future::Future<Output = Result<()>>,
{
let path = socket2_path()?;
let stream = UnixStream::connect(&path)
.await
.with_context(|| format!("connect {}", path.display()))?;
let mut lines = BufReader::new(stream).lines();
while let Some(line) = lines.next_line().await? {
on_line(line).await?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bind_command_has_one_ipc() {
let cmd = bind_command("/tmp/enboxer", "/tmp/enboxer.sock", "hotkey Alt+G");
assert_eq!(
cmd,
"'/tmp/enboxer' ipc --sock '/tmp/enboxer.sock' 'hotkey Alt+G'"
);
assert_eq!(cmd.matches(" ipc ").count(), 1);
}
#[test]
fn bind_command_quotes_args_with_metacharacters() {
// shell_single must escape a hostile arg (semicolon, backtick,
// dollar-paren) so it cannot inject into Hyprland's exec_cmd.
let s = bind_command(
"enboxer",
"/tmp/enboxer.sock",
"hotkey Alt+G; cat /etc/passwd",
);
assert!(s.starts_with("'enboxer' ipc --sock '/tmp/enboxer.sock' "));
assert!(s.contains("'hotkey Alt+G; cat /etc/passwd'"));
}
}