use crate::{bail, ResultType}; use std::{ collections::HashMap, path::{Path, PathBuf}, process::Command, }; use users::{get_current_uid, get_user_by_uid, os::unix::UserExt}; use sctk::{ output::OutputData, output::{OutputHandler, OutputState}, reexports::client::protocol::wl_output::WlOutput, reexports::client::{globals, Proxy}, reexports::client::{Connection, QueueHandle}, registry::{ProvidesRegistryState, RegistryState}, }; lazy_static::lazy_static! { pub static ref DISTRO: Distro = Distro::new(); } // to-do: There seems to be some runtime issue that causes the audit logs to be generated. // We may need to fix this and remove this workaround in the future. // // We use the pre-search method to find the command path to avoid the audit logs on some systems. // No idea why the audit logs happen. // Though the audit logs may disappear after rebooting. // // See https://github.com/rustdesk/rustdesk/discussions/11959 // // `ausearch -x /usr/share/rustdesk/rustdesk` will return // ... // time->Tue Jun 24 10:40:43 2025 // type=PROCTITLE msg=audit(1750776043.446:192757): proctitle=2F7573722F62696E2F727573746465736B002D2D73657276696365 // type=PATH msg=audit(1750776043.446:192757): item=0 name="/usr/local/bin/sh" nametype=UNKNOWN cap_fp=0 cap_fi=0 cap_fe=0 cap_fver=0 cap_frootid=0 // type=CWD msg=audit(1750776043.446:192757): cwd="/" // type=SYSCALL msg=audit(1750776043.446:192757): arch=c000003e syscall=59 success=no exit=-2 a0=7fb7dbd22da0 a1=1d65f2c0 a2=7ffc25193360 a3=7ffc25194ec0 items=1 ppid=172208 pid=267565 auid=4294967295 uid=0 gid=0 euid=0 suid=0 fsuid=0 egid=0 sgid=0 fsgid=0 tty=(none) ses=4294967295 comm="rustdesk" exe="/usr/share/rustdesk/rustdesk" subj=unconfined key="processos_criados" // ---- // time->Tue Jun 24 10:40:43 2025 // type=PROCTITLE msg=audit(1750776043.446:192758): proctitle=2F7573722F62696E2F727573746465736B002D2D73657276696365 // type=PATH msg=audit(1750776043.446:192758): item=0 name="/usr/sbin/sh" nametype=UNKNOWN cap_fp=0 cap_fi=0 cap_fe=0 cap_fver=0 cap_frootid=0 // ... lazy_static::lazy_static! { pub static ref CMD_LOGINCTL: String = find_cmd_path("loginctl"); pub static ref CMD_PS: String = find_cmd_path("ps"); pub static ref CMD_SH: String = find_cmd_path("sh"); } pub const DISPLAY_SERVER_WAYLAND: &str = "wayland"; pub const DISPLAY_SERVER_X11: &str = "x11"; pub const DISPLAY_DESKTOP_KDE: &str = "KDE"; pub const XDG_CURRENT_DESKTOP: &str = "XDG_CURRENT_DESKTOP"; pub struct Distro { pub name: String, pub version_id: String, } impl Distro { fn new() -> Self { let name = run_cmds("awk -F'=' '/^NAME=/ {print $2}' /etc/os-release") .unwrap_or_default() .trim() .trim_matches('"') .to_string(); let version_id = run_cmds("awk -F'=' '/^VERSION_ID=/ {print $2}' /etc/os-release") .unwrap_or_default() .trim() .trim_matches('"') .to_string(); Self { name, version_id } } } fn find_cmd_path(cmd: &'static str) -> String { let test_cmd = format!("/bin/{}", cmd); if std::path::Path::new(&test_cmd).exists() { return test_cmd; } let test_cmd = format!("/usr/bin/{}", cmd); if std::path::Path::new(&test_cmd).exists() { return test_cmd; } if let Ok(output) = Command::new("which").arg(cmd).output() { if output.status.success() { return String::from_utf8_lossy(&output.stdout).trim().to_string(); } } cmd.to_string() } // Deprecated. Use `hbb_common::platform::linux::is_kde_session()` instead for now. // Or we need to set the correct environment variable in the server process. #[inline] pub fn is_kde() -> bool { if let Ok(env) = std::env::var(XDG_CURRENT_DESKTOP) { env == DISPLAY_DESKTOP_KDE } else { false } } // Don't use `hbb_common::platform::linux::is_kde()` here. // It's not correct in the server process. pub fn is_kde_session() -> bool { std::process::Command::new(CMD_SH.as_str()) .arg("-c") .arg("pgrep -f kded[0-9]+") .stdout(std::process::Stdio::piped()) .output() .map(|o| !o.stdout.is_empty()) .unwrap_or(false) } #[inline] pub fn is_gdm_user(username: &str) -> bool { username == "gdm" || username == "sddm" // || username == "lightgdm" } #[inline] pub fn is_desktop_wayland() -> bool { get_display_server() == DISPLAY_SERVER_WAYLAND } #[inline] pub fn is_x11_or_headless() -> bool { !is_desktop_wayland() } // -1 const INVALID_SESSION: &str = "4294967295"; pub fn get_display_server() -> String { // Check for forced display server environment variable first if let Ok(forced_display) = std::env::var("RUSTDESK_FORCED_DISPLAY_SERVER") { return forced_display; } // Check if `loginctl` can be called successfully if run_loginctl(None).is_err() { return DISPLAY_SERVER_X11.to_owned(); } let mut session = get_values_of_seat0(&[0])[0].clone(); if session.is_empty() { // loginctl has not given the expected output. try something else. if let Ok(sid) = std::env::var("XDG_SESSION_ID") { // could also execute "cat /proc/self/sessionid" session = sid; } if session.is_empty() { session = run_cmds("cat /proc/self/sessionid").unwrap_or_default(); if session == INVALID_SESSION { session = "".to_owned(); } } } if session.is_empty() { std::env::var("XDG_SESSION_TYPE").unwrap_or("x11".to_owned()) } else { get_display_server_of_session(&session) } } pub fn get_display_server_of_session(session: &str) -> String { let mut display_server = if let Ok(output) = run_loginctl(Some(vec!["show-session", "-p", "Type", session])) // Check session type of the session { String::from_utf8_lossy(&output.stdout) .replace("Type=", "") .trim_end() .into() } else { "".to_owned() }; if display_server.is_empty() || display_server == "tty" || display_server == "unspecified" { if let Ok(sestype) = std::env::var("XDG_SESSION_TYPE") { if !sestype.is_empty() { return sestype.to_lowercase(); } } display_server = "x11".to_owned(); } display_server.to_lowercase() } #[inline] fn line_values(indices: &[usize], line: &str) -> Vec { indices .into_iter() .map(|idx| line.split_whitespace().nth(*idx).unwrap_or("").to_owned()) .collect::>() } #[inline] pub fn get_values_of_seat0(indices: &[usize]) -> Vec { _get_values_of_seat0(indices, true) } #[inline] pub fn get_values_of_seat0_with_gdm_wayland(indices: &[usize]) -> Vec { _get_values_of_seat0(indices, false) } // Ignore "3 sessions listed." fn ignore_loginctl_line(line: &str) -> bool { line.contains("sessions") || line.split(" ").count() < 4 } fn _get_values_of_seat0(indices: &[usize], ignore_gdm_wayland: bool) -> Vec { if let Ok(output) = run_loginctl(None) { for line in String::from_utf8_lossy(&output.stdout).lines() { if ignore_loginctl_line(line) { continue; } if line.contains("seat0") { if let Some(sid) = line.split_whitespace().next() { if is_active(sid) { if ignore_gdm_wayland { if is_gdm_user(line.split_whitespace().nth(2).unwrap_or("")) && get_display_server_of_session(sid) == DISPLAY_SERVER_WAYLAND { continue; } } return line_values(indices, line); } } } } // some case, there is no seat0 https://github.com/rustdesk/rustdesk/issues/73 for line in String::from_utf8_lossy(&output.stdout).lines() { if ignore_loginctl_line(line) { continue; } if let Some(sid) = line.split_whitespace().next() { if is_active(sid) { let d = get_display_server_of_session(sid); if ignore_gdm_wayland { if is_gdm_user(line.split_whitespace().nth(2).unwrap_or("")) && d == DISPLAY_SERVER_WAYLAND { continue; } } if d == "tty" || d == "unspecified" { continue; } return line_values(indices, line); } } } } line_values(indices, "") } pub fn is_active(sid: &str) -> bool { if let Ok(output) = run_loginctl(Some(vec!["show-session", "-p", "State", sid])) { String::from_utf8_lossy(&output.stdout).contains("active") } else { false } } pub fn is_active_and_seat0(sid: &str) -> bool { if let Ok(output) = run_loginctl(Some(vec!["show-session", sid])) { String::from_utf8_lossy(&output.stdout).contains("State=active") && String::from_utf8_lossy(&output.stdout).contains("Seat=seat0") } else { false } } // Check both "Lock" and "Switch user" pub fn is_session_locked(sid: &str) -> bool { if let Ok(output) = run_loginctl(Some(vec!["show-session", sid, "--property=LockedHint"])) { String::from_utf8_lossy(&output.stdout).contains("LockedHint=yes") } else { false } } // **Note** that the return value here, the last character is '\n'. // Use `run_cmds_trim_newline()` if you want to remove '\n' at the end. pub fn run_cmds(cmds: &str) -> ResultType { let output = std::process::Command::new(CMD_SH.as_str()) .args(vec!["-c", cmds]) .output()?; Ok(String::from_utf8_lossy(&output.stdout).to_string()) } pub fn run_cmds_trim_newline(cmds: &str) -> ResultType { let output = std::process::Command::new(CMD_SH.as_str()) .args(vec!["-c", cmds]) .output()?; let out = String::from_utf8_lossy(&output.stdout); Ok(if out.ends_with('\n') { out[..out.len() - 1].to_string() } else { out.to_string() }) } fn run_loginctl(args: Option>) -> std::io::Result { if std::env::var("FLATPAK_ID").is_ok() { let mut l_args = CMD_LOGINCTL.to_string(); if let Some(a) = args.as_ref() { l_args = format!("{} {}", l_args, a.join(" ")); } let res = std::process::Command::new("flatpak-spawn") .args(vec![String::from("--host"), l_args]) .output(); if res.is_ok() { return res; } } let mut cmd = std::process::Command::new(CMD_LOGINCTL.as_str()); if let Some(a) = args { return cmd.args(a).output(); } cmd.output() } /// forever: may not work #[cfg(target_os = "linux")] pub fn system_message(title: &str, msg: &str, forever: bool) -> ResultType<()> { let cmds: HashMap<&str, Vec<&str>> = HashMap::from([ ("notify-send", [title, msg].to_vec()), ( "zenity", [ "--info", "--timeout", if forever { "0" } else { "3" }, "--title", title, "--text", msg, ] .to_vec(), ), ("kdialog", ["--title", title, "--msgbox", msg].to_vec()), ( "xmessage", [ "-center", "-timeout", if forever { "0" } else { "3" }, title, msg, ] .to_vec(), ), ]); for (k, v) in cmds { if Command::new(k).args(v).spawn().is_ok() { return Ok(()); } } crate::bail!("failed to post system message"); } #[derive(Debug, Clone, serde_derive::Serialize, serde_derive::Deserialize)] pub struct WaylandDisplayInfo { pub name: String, pub x: i32, pub y: i32, pub width: i32, pub height: i32, pub logical_size: Option<(i32, i32)>, pub refresh_rate: i32, } #[cfg(target_os = "linux")] const RUNTIME_DIR_PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2); /// The argument the consumer binary must dispatch to `wayland_display_probe_child_main` before /// any other startup work; see that function for why the probe is its own process. #[cfg(target_os = "linux")] pub const WAYLAND_DISPLAY_PROBE_ARG: &str = "--wayland-display-probe"; /// First stdout line of a probe child. A binary that does not dispatch the arg never prints it. #[cfg(target_os = "linux")] const WAYLAND_PROBE_MAGIC: &str = "wayland-display-probe-v1"; /// Latched on a failed handshake: a consumer that does not dispatch the probe arg runs its NORMAL /// startup instead, and this path re-enters every enumeration cycle. #[cfg(target_os = "linux")] static PROBE_UNSUPPORTED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); #[cfg(target_os = "linux")] static RUNTIME_DIR_PROBE_BUSY: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); /// Clears the in-flight flag on every exit path of the parent, error arms included. #[cfg(target_os = "linux")] struct ProbeBusyGuard; #[cfg(target_os = "linux")] impl Drop for ProbeBusyGuard { fn drop(&mut self) { RUNTIME_DIR_PROBE_BUSY.store(false, std::sync::atomic::Ordering::Release); } } /// Entry point of the isolated probe process. The consumer binary dispatches /// `WAYLAND_DISPLAY_PROBE_ARG` here first, before config, logging or any other startup work. /// /// Its own process because the release profile builds with panic=abort: sctk panics on malformed /// protocol bytes, and in-process that abort takes the whole server down. Here it takes down only /// this child, which the parent reports as a failed probe. The seat0 lookup also runs in here, so /// the parent's single deadline bounds the loginctl reads too. #[cfg(target_os = "linux")] pub fn wayland_display_probe_child_main() -> ! { use std::io::Write; // The handshake first, so the parent can tell this entry point ran and not a consumer binary // that fell through to its normal startup. println!("{WAYLAND_PROBE_MAGIC}"); let _ = std::io::stdout().flush(); let code = match seat0_runtime_dir() .and_then(|dir| { drop_to_dir_owner(&dir)?; probe_runtime_dir(&dir) }) .and_then(|displays| serde_json::to_string(&displays).map_err(anyhow::Error::from)) { Ok(json) => { println!("{json}"); 0 } Err(err) => { eprintln!("{err:#}"); 1 } }; let _ = std::io::stdout().flush(); std::process::exit(code) } #[cfg(target_os = "linux")] static ENDPOINT_WAS_NAMED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); /// Whether the environment ever named a wayland endpoint in this process. Empty is not a name. /// /// Read before `connect_to_env`, which removes `WAYLAND_SOCKET` from the environment on both its /// success and its bad-fd path; and latched, so a consumed variable cannot turn a process that WAS /// pointed at a compositor into one that is free to go looking for another. #[cfg(target_os = "linux")] fn env_names_wayland_endpoint() -> bool { use std::sync::atomic::Ordering; let named = ["WAYLAND_DISPLAY", "WAYLAND_SOCKET"] .iter() .any(|key| std::env::var_os(key).is_some_and(|value| !value.is_empty())); if named { ENDPOINT_WAS_NAMED.store(true, Ordering::Release); } ENDPOINT_WAS_NAMED.load(Ordering::Acquire) } /// `/run/user/` of the active seat0 session, a greeter included. /// /// Derived from the uid rather than read from `XDG_RUNTIME_DIR`: the root service is given no such /// variable, and `get_home_dir_trusted` below refuses to trust the environment for the same reason. #[cfg(target_os = "linux")] /// The probe parses compositor-controlled protocol data; a root service must not do that as /// root. Before touching the socket, become the runtime directory's owner — and refuse to probe /// at all if the drop fails, since staying root is the one unacceptable outcome. #[cfg(target_os = "linux")] fn drop_to_dir_owner(dir: &Path) -> ResultType<()> { if unsafe { libc::geteuid() } != 0 { return Ok(()); } use std::os::unix::fs::MetadataExt; let meta = std::fs::metadata(dir)?; let (uid, gid) = (meta.uid(), meta.gid()); if uid == 0 { // Root's own session: there is no boundary to cross and nothing to drop to. return Ok(()); } unsafe { if libc::setgroups(0, std::ptr::null()) != 0 || libc::setgid(gid) != 0 || libc::setuid(uid) != 0 || libc::setuid(0) == 0 { bail!("could not drop privileges for the socket probe"); } } Ok(()) } fn seat0_runtime_dir() -> ResultType { let uid = get_values_of_seat0_with_gdm_wayland(&[1]).remove(0); if uid.is_empty() || !uid.bytes().all(|b| b.is_ascii_digit()) { bail!("no active seat0 session to take a runtime directory from"); } Ok(PathBuf::from(format!("/run/user/{uid}"))) } /// The wayland sockets present in `dir`, lowest display number first. /// /// Scanned rather than guessed: `wl_display_add_socket_auto` takes the first FREE name up to /// `wayland-32`, and a greeter is where leftovers accumulate across compositor restarts. Only that /// name pattern, because the same directory holds pipewire and dbus sockets. #[cfg(target_os = "linux")] fn wayland_sockets_in(dir: &Path) -> Vec { use std::os::unix::fs::FileTypeExt; let mut paths: Vec = match std::fs::read_dir(dir) { Ok(entries) => entries .flatten() .filter(|entry| { let name = entry.file_name(); let name = name.to_string_lossy(); name.starts_with("wayland-") && !name.ends_with(".lock") && entry.file_type().map(|t| t.is_socket()).unwrap_or(false) }) .map(|entry| entry.path()) .collect(), Err(_) => Vec::new(), }; paths.sort_by_key(|path| { path.file_name() .and_then(|name| name.to_str()) .and_then(|name| name.strip_prefix("wayland-")) .and_then(|number| number.parse::().ok()) .unwrap_or(u32::MAX) }); paths } /// Enumerate through a socket in the seat0 runtime directory, for the case where nothing named an /// endpoint: a greeter's `--server` and the root service are given no compositor variables, so /// nothing tells the enumerator where a compositor that IS running lives. An endpoint that WAS /// named and failed must not silently reattach to a different compositor. /// /// In a subprocess and bounded, because the caller holds a process-wide lock across the call while /// `connect(2)` parks on a full backlog and sctk's roundtrip polls without a deadline; and because /// sctk panics on malformed output events, which the release profile's panic=abort turns into an /// abort of the whole server. A child dies alone, and on the deadline it is killed instead of /// leaking a thread. The seat0 lookup runs inside the child, under the same deadline. #[cfg(target_os = "linux")] fn wayland_displays_from_runtime_dir(named_endpoint: bool) -> ResultType> { use std::io::Read; use std::sync::atomic::Ordering; if named_endpoint { bail!("an explicit wayland endpoint is set and did not connect"); } if PROBE_UNSUPPORTED.load(Ordering::Acquire) { bail!("this binary does not dispatch {WAYLAND_DISPLAY_PROBE_ARG}"); } if RUNTIME_DIR_PROBE_BUSY.swap(true, Ordering::AcqRel) { bail!("an earlier probe has not returned"); } let _busy = ProbeBusyGuard; let exe = std::env::current_exe()?; // Its own process group, so the deadline can kill loginctl descendants along with the child, // and so no surviving descendant can hold the pipes open past the reads below. use std::os::unix::process::CommandExt; let mut child = std::process::Command::new(exe) .arg(WAYLAND_DISPLAY_PROBE_ARG) .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) .process_group(0) .spawn()?; let probe_pgid = child.id() as libc::pid_t; let kill_probe_group = || unsafe { let _ = libc::kill(-probe_pgid, libc::SIGKILL); }; let deadline = std::time::Instant::now() + RUNTIME_DIR_PROBE_TIMEOUT; let status = loop { match child.try_wait()? { Some(status) => { kill_probe_group(); break status; } None if std::time::Instant::now() >= deadline => { kill_probe_group(); let _ = child.wait(); // An unwired binary runs its normal startup, and a long-running one (the // server itself) lands HERE rather than at the handshake check below — latch // on this path too, or every enumeration cycle spawns a full consumer // process. Judged by what the child already wrote: a real probe prints the // magic line first and flushes, so its absence after a whole deadline means // this is not a probe. Only buffered bytes are read — a blocking read could // hang on a grandchild that inherited the write end. match first_buffered_line(child.stdout.take()) { // The pipe could not be inspected at all: no evidence, no latch. None => { bail!("the wayland socket probe timed out and its output was uninspectable") } Some(head) if head.as_deref() == Some(WAYLAND_PROBE_MAGIC) => { bail!("the wayland socket probe did not answer and was killed"); } Some(_) => { PROBE_UNSUPPORTED.store(true, Ordering::Release); bail!("the wayland socket probe timed out without the handshake; probe disabled"); } } } None => std::thread::sleep(std::time::Duration::from_millis(25)), } }; let mut stdout = String::new(); let mut stderr = String::new(); if let Some(mut pipe) = child.stdout.take() { let _ = pipe.read_to_string(&mut stdout); } if let Some(mut pipe) = child.stderr.take() { let _ = pipe.read_to_string(&mut stderr); } let mut lines = stdout.lines(); if lines.next() != Some(WAYLAND_PROBE_MAGIC) { // Not a probe: the binary ran its normal startup. Latch, or this path would spawn one // full consumer process per enumeration cycle. PROBE_UNSUPPORTED.store(true, Ordering::Release); bail!("this binary does not dispatch {WAYLAND_DISPLAY_PROBE_ARG}; probe disabled"); } if !status.success() { let detail = stderr.trim(); if detail.is_empty() { // panic=abort or a signal leaves stderr empty; the status is then the only cause. bail!("wayland socket probe failed: {status}"); } bail!("wayland socket probe failed ({status}): {detail}"); } let displays: Vec = match serde_json::from_str(lines.next().unwrap_or_default()) { Ok(displays) => displays, Err(err) => bail!("wayland socket probe answered a malformed list: {err}"), }; // The child already refuses an empty list; refuse it here too, so a truncated pipe cannot // become a cached-for-life empty enumeration. if displays.is_empty() { bail!("wayland socket probe returned no outputs"); } log::debug!( "wayland: {} output(s) via the probe subprocess", displays.len() ); Ok(displays) } /// The first line already sitting in the pipe buffer, read strictly non-blocking: children of a /// killed consumer can inherit the write end and keep it open, so an EOF-seeking read here could /// hang the enumeration forever. Outer `None` means the pipe could not be INSPECTED (missing /// handle, fcntl or read failure) and must not be read as evidence of anything; `Some(None)` is /// an inspected-and-empty buffer. #[cfg(target_os = "linux")] fn first_buffered_line(pipe: Option) -> Option> { use std::io::Read; use std::os::fd::AsRawFd; let mut pipe = pipe?; let fd = pipe.as_raw_fd(); unsafe { let flags = libc::fcntl(fd, libc::F_GETFL); if flags < 0 || libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) < 0 { return None; } } // The magic line is written in one flush and fits many times over; one read is enough. let mut buf = vec![0u8; 256]; match pipe.read(&mut buf) { Ok(n) => { buf.truncate(n); Some( String::from_utf8_lossy(&buf) .lines() .next() .map(str::to_owned), ) } // A drained pipe answers WouldBlock here, and an empty buffer after a whole deadline IS // evidence; any error still counts as uninspectable. Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => Some(None), Err(_) => None, } } #[cfg(target_os = "linux")] fn probe_runtime_dir(dir: &Path) -> ResultType> { use std::os::unix::net::UnixStream; let mut errs = Vec::new(); for path in wayland_sockets_in(dir) { match UnixStream::connect(&path) .map_err(anyhow::Error::from) .and_then(|s| Connection::from_socket(s).map_err(anyhow::Error::from)) .and_then(|conn| collect_wayland_displays(&conn)) { // The caller caches an empty list as ground truth for the process lifetime, and a // compositor still probing its monitors is exactly what this path connects to. Ok(displays) if displays.is_empty() => { errs.push(format!("{}: no outputs yet", path.display())) } Ok(displays) => { // Which socket answered, when nothing in the environment named one. log::debug!( "wayland: {} output(s) from {}, found by scanning", displays.len(), path.display() ); return Ok(displays); } Err(err) => errs.push(format!("{}: {err}", path.display())), } } bail!( "no usable wayland socket in {} ({})", dir.display(), if errs.is_empty() { "none present".to_owned() } else { errs.join("; ") } ) } // Retrieves information about all connected displays via the Wayland protocol. pub fn get_wayland_displays() -> ResultType> { // Read before connecting: `connect_to_env` consumes `WAYLAND_SOCKET`. let named_endpoint = env_names_wayland_endpoint(); match Connection::connect_to_env() { Ok(conn) => collect_wayland_displays(&conn), Err(err) => wayland_displays_from_runtime_dir(named_endpoint) .map_err(|fallback_err| anyhow::anyhow!("{err}; {fallback_err}")), } } fn collect_wayland_displays(conn: &Connection) -> ResultType> { struct WaylandEnv { registry_state: RegistryState, output_state: OutputState, } impl OutputHandler for WaylandEnv { fn output_state(&mut self) -> &mut OutputState { &mut self.output_state } fn new_output(&mut self, _: &Connection, _: &QueueHandle, _: WlOutput) {} fn update_output(&mut self, _: &Connection, _: &QueueHandle, _: WlOutput) {} fn output_destroyed(&mut self, _: &Connection, _: &QueueHandle, _: WlOutput) {} } impl ProvidesRegistryState for WaylandEnv { fn registry(&mut self) -> &mut RegistryState { &mut self.registry_state } sctk::registry_handlers![OutputState]; } sctk::delegate_output!(WaylandEnv); sctk::delegate_registry!(WaylandEnv); let (globals, mut event_queue) = globals::registry_queue_init(conn)?; let queue_handle = event_queue.handle(); let registry_state = RegistryState::new(&globals); let output_state = OutputState::new(&globals, &queue_handle); let mut environment = WaylandEnv { registry_state, output_state, }; event_queue.roundtrip(&mut environment)?; let outputs: Vec<_> = environment.output_state.outputs().collect(); let mut display_infos = Vec::new(); for output in outputs { if let Some(output_data) = output.data::() { output_data.with_output_info(|info| { if let Some(mode) = info.modes.iter().find(|m| m.current) { let (x, y) = info.location; let (width, height) = mode.dimensions; let refresh_rate = mode.refresh_rate; let name = info.name.clone().unwrap_or_default(); let logical_size = info.logical_size; display_infos.push(WaylandDisplayInfo { name, x, y, width, height, logical_size, refresh_rate, }); } }); } } Ok(display_infos) } /// Escape a string for safe use in shell commands by wrapping in single quotes. /// /// This function handles the edge case of single quotes within the string by: /// 1. Ending the current single-quoted section /// 2. Adding an escaped single quote /// 3. Starting a new single-quoted section /// /// Example: "it's here" -> "'it'\''s here'" #[inline] pub fn shell_quote(s: &str) -> String { format!("'{}'", s.replace("'", "'\\''")) } /// Get the current user's home directory via getpwuid (trusted source). /// /// This function uses the system's password database (via `getpwuid`) to retrieve /// the home directory, avoiding the security risk of relying on the `HOME` /// environment variable which can be manipulated by untrusted input. /// /// # Returns /// - `Some(PathBuf)` if the home directory was found and exists /// - `None` if the user lookup failed or the directory doesn't exist /// /// # Security /// This function is designed to be safe against confused-deputy attacks where /// an attacker might manipulate environment variables to influence privileged /// operations. pub fn get_home_dir_trusted() -> Option { let uid = get_current_uid(); match get_user_by_uid(uid) { Some(user) => { let home = user.home_dir(); if Path::is_dir(home) { Some(PathBuf::from(home)) } else { log::warn!( "Home directory for uid {} does not exist or is not a directory: {:?}", uid, home ); None } } None => { log::warn!("Failed to get user info for uid {}", uid); None } } } #[cfg(test)] mod tests { use super::*; #[test] fn test_run_cmds_trim_newline() { assert_eq!(run_cmds_trim_newline("echo -n 123").unwrap(), "123"); assert_eq!(run_cmds_trim_newline("echo 123").unwrap(), "123"); assert_eq!( run_cmds_trim_newline("whoami").unwrap() + "\n", run_cmds("whoami").unwrap() ); } /// Test get_home_dir_trusted: returns valid path and ignores HOME env var #[test] fn test_get_home_dir_trusted() { let original_home = std::env::var("HOME").ok(); // Set HOME to a fake/malicious path std::env::set_var("HOME", "/tmp/fake_malicious_home"); let result = get_home_dir_trusted(); // Restore original HOME match original_home { Some(home) => std::env::set_var("HOME", home), None => std::env::remove_var("HOME"), } // Verify: returns valid path that is NOT the fake HOME if let Some(path) = result { assert!(path.is_absolute(), "Path should be absolute: {:?}", path); assert!(path.is_dir(), "Path should be a directory: {:?}", path); assert_ne!( path.to_string_lossy(), "/tmp/fake_malicious_home", "Should not use HOME env var" ); } } /// Test shell_quote with normal strings #[test] fn test_shell_quote_normal() { assert_eq!(shell_quote("hello"), "'hello'"); assert_eq!(shell_quote("/home/user"), "'/home/user'"); } /// Test shell_quote with spaces #[test] fn test_shell_quote_spaces() { assert_eq!(shell_quote("/home/my user/file"), "'/home/my user/file'"); assert_eq!(shell_quote("path with spaces"), "'path with spaces'"); } /// Test shell_quote with single quotes (the tricky case) #[test] fn test_shell_quote_single_quotes() { assert_eq!(shell_quote("it's"), "'it'\\''s'"); assert_eq!(shell_quote("don't stop"), "'don'\\''t stop'"); } /// Test shell_quote with shell metacharacters #[test] fn test_shell_quote_metacharacters() { // These should all be safely quoted assert_eq!(shell_quote("test;rm -rf /"), "'test;rm -rf /'"); assert_eq!(shell_quote("$(whoami)"), "'$(whoami)'"); assert_eq!(shell_quote("`id`"), "'`id`'"); assert_eq!(shell_quote("a && b"), "'a && b'"); assert_eq!(shell_quote("a | b"), "'a | b'"); } }