//! Session discovery, environment construction, and child lifecycle. use std::{ ffi::{CString, OsStr}, fs::{self, File, OpenOptions}, io::{self, Write}, os::unix::{ fs::{MetadataExt, OpenOptionsExt}, io::AsRawFd, process::CommandExt, }, path::{Path, PathBuf}, process::{Child, Command, Stdio}, sync::{ Arc, atomic::{AtomicBool, Ordering}, }, thread, time::{Duration, Instant}, }; use crate::{ accounting::UtmpxGuard, accounts::Identity, config::Config, error::{Error, ResultExt, io_error}, }; use rand::RngCore; use signal_hook::{consts::SIGUSR1, flag}; /// A session family supported by latchd. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SessionKind { /// A login shell on the current virtual terminal. Tty, /// An X11 desktop session. X11, /// A Wayland desktop session. Wayland, } /// A discoverable desktop or script session. #[derive(Debug, Clone)] pub struct SessionDescriptor { /// Session family. pub kind: SessionKind, /// User-facing title. pub title: String, /// Program and arguments, without shell interpretation. pub argv: Vec, /// XDG current-desktop value. pub current_desktop: Option, /// XDG session-desktop value. pub session_desktop: Option, } /// A fully materialized session request. #[derive(Debug, Clone)] pub struct SessionSpec { /// Session family. pub kind: SessionKind, /// Session title for logs. pub title: String, /// Program and arguments. pub argv: Vec, /// Explicit child environment. pub environment: Vec<(String, String)>, /// Working directory. pub working_directory: PathBuf, /// Private stdout/stderr log for graphical sessions. pub client_log_path: PathBuf, /// Login shell for TTY fallback. pub shell: PathBuf, /// Virtual terminal number. pub tty: u8, /// X11 configuration when this is an X11 session. pub x11: Option, } /// Runtime X11 parameters passed to a session. #[derive(Debug, Clone)] pub struct X11Runtime { /// X display name. pub display: String, /// X server command. pub server_path: PathBuf, /// Xauthority command. pub xauth_path: PathBuf, /// X server timeout. pub timeout: Duration, /// X server log path. pub log_path: PathBuf, } /// Discovers configured sessions in stable directory order. pub fn discover(config: &Config) -> Vec { let mut sessions = Vec::new(); scan_desktop_dir( &config.x11.sessions_path, SessionKind::X11, &config.initial_path, &mut sessions, ); scan_desktop_dir( &config.wayland.sessions_path, SessionKind::Wayland, &config.initial_path, &mut sessions, ); scan_script_dir(&config.x11.scripts_path, SessionKind::X11, &mut sessions); scan_script_dir( &config.wayland.scripts_path, SessionKind::Wayland, &mut sessions, ); if sessions.is_empty() || config.environment_switcher.include_tty_shell { sessions.push(SessionDescriptor { kind: SessionKind::Tty, title: "TTY shell".into(), argv: Vec::new(), current_desktop: None, session_desktop: None, }); } sessions } /// Checks configured binaries and discovered session entries without starting them. pub fn preflight(config: &Config) -> Result<(), Error> { let shell = secure_executable(&config.system_shell, "configured system shell")?; if !shell.is_file() { return Err(Error::Config(format!( "configured system shell is not a regular file: {}", shell.display() ))); } let sessions = discover(config); for session in &sessions { if session.kind == SessionKind::Tty { continue; } let Some(program) = session.argv.first() else { return Err(Error::Config(format!( "session {} has no executable", session.title ))); }; let executable = find_executable(program, &config.initial_path).ok_or_else(|| { Error::Config(format!( "session {} executable is not available: {program}", session.title )) })?; secure_executable(&executable, "session executable")?; } if sessions .iter() .any(|session| session.kind == SessionKind::X11) { secure_executable(&config.x11.server_path, "X11 server")?; secure_executable(&config.x11.xauth_path, "xauth")?; } Ok(()) } fn find_executable(program: &str, path: &str) -> Option { let candidate = Path::new(program); if candidate.is_absolute() { return candidate.exists().then(|| candidate.to_owned()); } path.split(':') .map(|directory| Path::new(directory).join(program)) .find(|candidate| candidate.is_file()) } fn secure_executable(path: &Path, label: &str) -> Result { let resolved = fs::canonicalize(path).io_context(format!("resolve {label} {}", path.display()))?; let metadata = fs::symlink_metadata(&resolved) .io_context(format!("inspect {label} {}", resolved.display()))?; if !metadata.is_file() || metadata.uid() != 0 || metadata.mode() & 0o022 != 0 { return Err(Error::Config(format!( "{label} must resolve to a root-owned, non-writable regular file: {}", path.display() ))); } if metadata.mode() & 0o111 == 0 { return Err(Error::Config(format!( "{label} is not executable: {}", path.display() ))); } Ok(resolved) } /// Builds an explicit environment and process specification for one identity. pub fn build_spec( identity: &Identity, config: &Config, descriptor: &SessionDescriptor, ) -> Result { let mut environment = inherited_safe_environment(); insert_env( &mut environment, "HOME", identity.home.to_string_lossy().into_owned(), ); insert_env( &mut environment, "SHELL", identity.shell.to_string_lossy().into_owned(), ); insert_env(&mut environment, "USER", identity.username.clone()); insert_env(&mut environment, "LOGNAME", identity.username.clone()); insert_env(&mut environment, "PATH", config.initial_path.clone()); insert_env( &mut environment, "PWD", identity.home.to_string_lossy().into_owned(), ); insert_env(&mut environment, "XDG_SESSION_CLASS", "user".into()); insert_env( &mut environment, "XDG_SESSION_TYPE", match descriptor.kind { SessionKind::Tty => "tty", SessionKind::X11 => "x11", SessionKind::Wayland => "wayland", } .into(), ); insert_env(&mut environment, "XDG_SEAT", "seat0".into()); insert_env(&mut environment, "XDG_VTNR", config.tty.to_string()); insert_env( &mut environment, "XDG_RUNTIME_DIR", format!("/run/user/{}", identity.uid), ); insert_env(&mut environment, "XDG_SESSION_ID", "latchd".into()); insert_env( &mut environment, "XDG_CONFIG_HOME", identity.home.join(".config").to_string_lossy().into_owned(), ); insert_env( &mut environment, "XDG_CACHE_HOME", identity.home.join(".cache").to_string_lossy().into_owned(), ); insert_env( &mut environment, "XDG_DATA_HOME", identity .home .join(".local/share") .to_string_lossy() .into_owned(), ); insert_env( &mut environment, "XDG_STATE_HOME", identity .home .join(".local/state") .to_string_lossy() .into_owned(), ); insert_env( &mut environment, "XDG_DATA_DIRS", "/usr/local/share:/usr/share".into(), ); insert_env(&mut environment, "XDG_CONFIG_DIRS", "/etc/xdg".into()); let x11 = if descriptor.kind == SessionKind::X11 { insert_env(&mut environment, "DISPLAY", config.x11.display.clone()); Some(X11Runtime { display: config.x11.display.clone(), server_path: config.x11.server_path.clone(), xauth_path: config.x11.xauth_path.clone(), timeout: Duration::from_secs(config.x11.server_timeout_secs as u64), log_path: config.x11.server_log_path.clone(), }) } else { None }; if let Some(value) = descriptor.current_desktop.as_deref() { insert_env(&mut environment, "XDG_CURRENT_DESKTOP", value.to_owned()); } if let Some(value) = descriptor.session_desktop.as_deref() { insert_env(&mut environment, "XDG_SESSION_DESKTOP", value.to_owned()); } let mut argv = if descriptor.kind == SessionKind::Tty { Vec::new() } else if descriptor.argv.is_empty() { return Err(Error::Session(format!( "session {} has no executable", descriptor.title ))); } else { descriptor.argv.clone() }; if let Some(program) = argv.first_mut() { if !Path::new(program).is_absolute() { let resolved = find_executable(program, &config.initial_path).ok_or_else(|| { Error::Session(format!( "session {} executable is not available: {program}", descriptor.title )) })?; *program = resolved.to_string_lossy().into_owned(); } } if !identity.shell.is_absolute() { return Err(Error::Session( "account shell must be an absolute path".into(), )); } Ok(SessionSpec { kind: descriptor.kind, title: descriptor.title.clone(), argv, environment, working_directory: identity.home.clone(), client_log_path: config.client_log_path.clone(), shell: identity.shell.clone(), tty: config.tty, x11, }) } /// Runs a user session and owns all child process cleanup. pub fn run_user_session(identity: &Identity, spec: &SessionSpec) -> Result<(), Error> { let home_metadata = fs::symlink_metadata(&identity.home) .map_err(|error| io_error(format!("inspect home {}", identity.home.display()), error))?; if !home_metadata.is_dir() || (home_metadata.uid() != identity.uid && home_metadata.uid() != 0) || home_metadata.mode() & 0o022 != 0 { return Err(Error::Session(format!( "home directory is missing or insecure: {}", identity.home.display() ))); } if spec.kind == SessionKind::X11 { return run_x11(identity, spec); } let mut command = user_command(identity, spec)?; let mut child = command.spawn().io_context("spawn user session")?; let mut accounting = match UtmpxGuard::start(&identity.username, spec.tty, child.id() as _) { Ok(guard) => guard, Err(error) => { let _ = terminate_child(&mut child); return Err(error); } }; let status = match child.wait().io_context("wait for user session") { Ok(status) => status, Err(error) => { let _ = terminate_child(&mut child); return Err(error); } }; if !status.success() { let _ = accounting.close(); return Err(Error::Session(format!( "{} exited with {status}", spec.title ))); } accounting.close()?; Ok(()) } fn user_command(identity: &Identity, spec: &SessionSpec) -> Result { let (program, args): (&OsStr, &[String]) = if spec.kind == SessionKind::Tty { (spec.shell.as_os_str(), &[] as &[String]) } else { let program = spec .argv .first() .ok_or_else(|| Error::Session("empty session argv".into()))?; (OsStr::new(program), &spec.argv[1..]) }; let mut command = Command::new(program); command .args(args) .current_dir(&spec.working_directory) .env_clear() .envs(spec.environment.iter().map(|(key, value)| (key, value))) .stdin(Stdio::inherit()) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()); if spec.kind != SessionKind::Tty { let output = private_log(&spec.client_log_path)?; let error = output.try_clone().io_context("clone client log")?; command .stdout(Stdio::from(output)) .stderr(Stdio::from(error)); } install_user_credentials(&mut command, identity, spec.kind == SessionKind::Tty)?; Ok(command) } fn install_user_credentials( command: &mut Command, identity: &Identity, controlling_tty: bool, ) -> Result<(), Error> { let username = CString::new(identity.username.as_str()) .map_err(|_| Error::Session("username contains NUL".into()))?; let uid = identity.uid; let gid = identity.gid; unsafe { command.pre_exec(move || { if libc::setsid() == -1 { return Err(io::Error::last_os_error()); } if controlling_tty && libc::ioctl(libc::STDIN_FILENO, libc::TIOCSCTTY as _, 0) == -1 { return Err(io::Error::last_os_error()); } if libc::initgroups(username.as_ptr(), gid) == -1 { return Err(io::Error::last_os_error()); } if libc::setgid(gid) == -1 || libc::setuid(uid) == -1 { return Err(io::Error::last_os_error()); } Ok(()) }); } Ok(()) } fn run_x11(identity: &Identity, spec: &SessionSpec) -> Result<(), Error> { let runtime = spec .x11 .as_ref() .ok_or_else(|| Error::Session("missing X11 runtime configuration".into()))?; let authority = create_xauthority(identity, runtime)?; let ready = Arc::new(AtomicBool::new(false)); flag::register(SIGUSR1, Arc::clone(&ready)) .map_err(|error| Error::Session(format!("register X11 readiness signal: {error}")))?; let x_log = private_log(&runtime.log_path)?; let x_log_err = x_log.try_clone().io_context("clone Xorg log")?; let mut server = Command::new(&runtime.server_path); let vt = format!("vt{}", spec.tty); server .args([runtime.display.as_str(), vt.as_str()]) .arg("-auth") .arg(authority.path()) .stdin(Stdio::null()) .stdout(Stdio::from(x_log)) .stderr(Stdio::from(x_log_err)); unsafe { server.pre_exec(|| { if libc::setsid() == -1 { return Err(io::Error::last_os_error()); } Ok(()) }); } let mut xserver = server.spawn().io_context("spawn X server")?; let deadline = Instant::now() + runtime.timeout; while !ready.load(Ordering::Acquire) { if let Some(status) = xserver.try_wait().io_context("poll X server")? { return Err(Error::Session(format!("X server exited with {status}"))); } if runtime.timeout != Duration::ZERO && Instant::now() >= deadline { terminate_child(&mut xserver)?; return Err(Error::Session("X server readiness timeout".into())); } thread::sleep(Duration::from_millis(20)); } let mut environment = spec.environment.clone(); insert_env( &mut environment, "XAUTHORITY", authority.path().to_string_lossy().into_owned(), ); let mut client_spec = spec.clone(); client_spec.environment = environment; let result = user_command(identity, &client_spec).and_then(|mut client| { let mut child = client.spawn().io_context("spawn X11 client")?; match child.wait().io_context("wait for X11 client") { Ok(status) => Ok(status), Err(error) => { let _ = terminate_child(&mut child); Err(error) } } }); let cleanup = terminate_child(&mut xserver); cleanup?; let result = result?; if !result.success() { return Err(Error::Session(format!("X11 client exited with {result}"))); } Ok(()) } struct AuthorityFile(PathBuf); impl AuthorityFile { fn path(&self) -> &Path { &self.0 } } impl Drop for AuthorityFile { fn drop(&mut self) { let _ = fs::remove_file(&self.0); } } fn create_xauthority(identity: &Identity, runtime: &X11Runtime) -> Result { let mut cookie = [0u8; 16]; rand::rng().fill_bytes(&mut cookie); let path = identity .home .join(format!(".Xauthority.latchd-{}", std::process::id())); let file = OpenOptions::new() .write(true) .create_new(true) .mode(0o600) .open(&path) .io_context(format!("create {}", path.display()))?; let owner = unsafe { libc::fchown(file.as_raw_fd(), identity.uid, identity.gid) }; if owner == -1 { let error = io::Error::last_os_error(); let _ = fs::remove_file(&path); return Err(io_error(format!("chown {}", path.display()), error)); } drop(file); let mut command = Command::new(&runtime.xauth_path); command .arg("-f") .arg(&path) .stdin(Stdio::piped()) .stdout(Stdio::null()) .stderr(Stdio::null()); if let Err(error) = install_user_credentials(&mut command, identity, false) { let _ = fs::remove_file(&path); return Err(error); } let mut child = match command.spawn().io_context("spawn xauth") { Ok(child) => child, Err(error) => { let _ = fs::remove_file(&path); return Err(error); } }; if let Some(mut stdin) = child.stdin.take() { if let Err(error) = writeln!(stdin, "add {} . {}", runtime.display, hex_cookie(&cookie)) .io_context("write xauth command") { let _ = terminate_child(&mut child); let _ = fs::remove_file(&path); return Err(error); } } let status = match child.wait().io_context("wait for xauth") { Ok(status) => status, Err(error) => { let _ = fs::remove_file(&path); return Err(error); } }; if !status.success() { let _ = fs::remove_file(&path); return Err(Error::Session(format!("xauth exited with {status}"))); } Ok(AuthorityFile(path)) } fn hex_cookie(bytes: &[u8]) -> String { bytes.iter().map(|byte| format!("{byte:02x}")).collect() } fn terminate_child(child: &mut Child) -> Result<(), Error> { if child.try_wait().io_context("poll child")?.is_some() { return Ok(()); } unsafe { libc::kill(-(child.id() as i32), libc::SIGTERM); } let deadline = Instant::now() + Duration::from_secs(3); while Instant::now() < deadline { if child .try_wait() .io_context("wait for child termination")? .is_some() { return Ok(()); } thread::sleep(Duration::from_millis(25)); } unsafe { libc::kill(-(child.id() as i32), libc::SIGKILL); } child.wait().io_context("reap killed child")?; Ok(()) } fn private_log(path: &Path) -> Result { const MAX_LOG_BYTES: u64 = 64 * 1024 * 1024; let parent = path .parent() .ok_or_else(|| Error::Config(format!("log path has no parent: {}", path.display())))?; let metadata = fs::symlink_metadata(parent) .io_context(format!("inspect log directory {}", parent.display()))?; if !metadata.is_dir() || metadata.uid() != 0 || metadata.mode() & 0o022 != 0 { return Err(Error::Config(format!( "log directory must be root-owned and not group-writable: {}", parent.display() ))); } let file = OpenOptions::new() .create(true) .append(true) .custom_flags(libc::O_NOFOLLOW) .mode(0o600) .open(path) .io_context(format!("open log {}", path.display()))?; if file .metadata() .io_context(format!("inspect log {}", path.display()))? .len() > MAX_LOG_BYTES { return Err(Error::Config(format!( "log exceeds {MAX_LOG_BYTES} bytes: {}", path.display() ))); } file.set_permissions(std::os::unix::fs::PermissionsExt::from_mode(0o600)) .io_context(format!("secure log {}", path.display()))?; Ok(file) } fn inherited_safe_environment() -> Vec<(String, String)> { std::env::vars() .filter(|(key, _)| key == "TERM" || key == "LANG" || key.starts_with("LC_")) .collect() } fn insert_env(environment: &mut Vec<(String, String)>, key: &str, value: String) { if let Some((_, existing)) = environment.iter_mut().find(|(name, _)| name == key) { *existing = value; } else { environment.push((key.into(), value)); } } fn scan_desktop_dir( path: &Path, kind: SessionKind, session_path: &str, output: &mut Vec, ) { let Ok(entries) = fs::read_dir(path) else { return; }; let mut entries: Vec<_> = entries.filter_map(Result::ok).collect(); entries.sort_by_key(|entry| entry.file_name()); for entry in entries { if entry.path().extension().and_then(|value| value.to_str()) != Some("desktop") { continue; } let Ok(metadata) = fs::symlink_metadata(entry.path()) else { continue; }; if !metadata.is_file() || metadata.mode() & 0o022 != 0 || metadata.uid() != 0 { continue; } let Ok(raw) = fs::read_to_string(entry.path()) else { continue; }; let fields = desktop_fields(&raw); if fields .get("Hidden") .is_some_and(|value| value.eq_ignore_ascii_case("true")) || fields .get("NoDisplay") .is_some_and(|value| value.eq_ignore_ascii_case("true")) { continue; } if fields .get("Type") .is_some_and(|value| !value.eq_ignore_ascii_case("Application")) { continue; } if let Some(try_exec) = fields.get("TryExec") && find_executable(try_exec, session_path).is_none() { continue; } let Some(exec) = fields.get("Exec").and_then(|value| parse_argv(value).ok()) else { continue; }; if exec.is_empty() { continue; } let title = fields .get("Name") .cloned() .unwrap_or_else(|| entry.file_name().to_string_lossy().into_owned()); let (current_desktop, session_desktop) = fields .get("DesktopNames") .map(|value| { ( Some(value.replace(';', ":")), value.split(';').next().map(str::to_owned), ) }) .unwrap_or((None, None)); output.push(SessionDescriptor { kind, title, argv: exec, current_desktop, session_desktop, }); } } fn scan_script_dir(path: &Path, kind: SessionKind, output: &mut Vec) { let Ok(entries) = fs::read_dir(path) else { return; }; let mut entries: Vec<_> = entries.filter_map(Result::ok).collect(); entries.sort_by_key(|entry| entry.file_name()); for entry in entries { let path = entry.path(); let Ok(metadata) = fs::symlink_metadata(&path) else { continue; }; if !metadata.is_file() || metadata.mode() & 0o111 == 0 || metadata.mode() & 0o022 != 0 || metadata.uid() != 0 { continue; } let file_name = entry.file_name(); let Some(title) = file_name.to_str() else { continue; }; output.push(SessionDescriptor { kind, title: title.into(), argv: vec![path.to_string_lossy().into_owned()], current_desktop: None, session_desktop: None, }); } } fn desktop_fields(raw: &str) -> std::collections::HashMap { let mut fields = std::collections::HashMap::new(); let mut in_desktop = false; for line in raw.lines().map(str::trim) { if line.starts_with('[') && line.ends_with(']') { in_desktop = line == "[Desktop Entry]"; continue; } if !in_desktop || line.is_empty() || line.starts_with('#') { continue; } if let Some((key, value)) = line.split_once('=') { fields.insert(key.trim().into(), value.trim().into()); } } fields } fn parse_argv(value: &str) -> Result, Error> { let mut output = Vec::new(); let mut current = String::new(); let mut quote = None; let mut escaped = false; for character in value.chars() { if escaped { current.push(character); escaped = false; continue; } match (quote, character) { (_, '\\') => escaped = true, (Some(expected), character) if expected == character => quote = None, (None, '\'' | '"') => quote = Some(character), (None, character) if character.is_whitespace() => { if !current.is_empty() { output.push(std::mem::take(&mut current)); } } (_, character) => current.push(character), } } if escaped || quote.is_some() { return Err(Error::Session("unterminated session command quote".into())); } if !current.is_empty() { output.push(current); } if output.iter().any(|argument| argument.contains('%')) { return Err(Error::Session( "desktop Exec field codes are not supported".into(), )); } Ok(output) } #[cfg(test)] mod tests { use super::*; use unicode_width::UnicodeWidthStr; #[test] fn parses_argv_without_shell_expansion() { let argv = parse_argv("/bin/example --title 'hello world'").expect("valid argv"); assert_eq!(argv, ["/bin/example", "--title", "hello world"]); } #[test] fn rejects_field_codes() { assert!(parse_argv("foo %f").is_err()); } #[test] fn display_width_is_not_byte_length() { assert_eq!(UnicodeWidthStr::width("界"), 2); } #[test] fn environment_does_not_copy_display_or_xauthority() { let identity = Identity { username: "alice".into(), uid: 1000, gid: 1000, home: "/home/alice".into(), shell: "/bin/sh".into(), }; let descriptor = SessionDescriptor { kind: SessionKind::Wayland, title: "test".into(), argv: vec!["/bin/true".into()], current_desktop: None, session_desktop: None, }; let spec = build_spec(&identity, &Config::default(), &descriptor).expect("valid spec"); assert!( spec.environment .iter() .all(|(key, _)| key != "DISPLAY" && key != "XAUTHORITY") ); } }