864 lines
27 KiB
Rust
864 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 serde::{Deserialize, Serialize};
|
|
use signal_hook::{consts::SIGUSR1, flag};
|
|
|
|
/// A session family supported by latchd.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "lowercase")]
|
|
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")
|
|
);
|
|
}
|
|
}
|