Files
latchd/crates/latchd-core/src/session.rs
Zane Walker faf5b27328 feat: ship Rust login manager and GitLab release pipeline
Replace the legacy Go implementation as the production path, quarantine the reference tree, add deterministic release packaging and version metadata, and run verification through GitLab CI.
2026-09-25 21:33:46 +05:30

862 lines
27 KiB
Rust

//! 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<String>,
/// XDG current-desktop value.
pub current_desktop: Option<String>,
/// XDG session-desktop value.
pub session_desktop: Option<String>,
}
/// 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<String>,
/// 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<X11Runtime>,
}
/// 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<SessionDescriptor> {
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<PathBuf> {
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<PathBuf, Error> {
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<SessionSpec, Error> {
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<Command, Error> {
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<AuthorityFile, Error> {
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<File, Error> {
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<SessionDescriptor>,
) {
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<SessionDescriptor>) {
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<String, String> {
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<Vec<String>, 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")
);
}
}