feat: complete session preferences and PAM fixture gates
This commit is contained in:
@@ -181,6 +181,10 @@ pub struct SwitcherConfig {
|
||||
pub include_tty_shell: bool,
|
||||
/// Maximum visible title width.
|
||||
pub max_display_length: usize,
|
||||
/// Restore each account's last successful session selection.
|
||||
pub remember_last_session: bool,
|
||||
/// Owner-checked file used for the last-session preferences.
|
||||
pub last_session_path: PathBuf,
|
||||
}
|
||||
|
||||
impl Default for SwitcherConfig {
|
||||
@@ -188,6 +192,8 @@ impl Default for SwitcherConfig {
|
||||
Self {
|
||||
include_tty_shell: false,
|
||||
max_display_length: 18,
|
||||
remember_last_session: true,
|
||||
last_session_path: PathBuf::from("/var/lib/latchd/last-session.toml"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -287,6 +293,13 @@ pub fn validate(config: &Config) -> Result<(), Error> {
|
||||
"environment_switcher.max_display_length must be greater than zero".into(),
|
||||
));
|
||||
}
|
||||
if config.environment_switcher.remember_last_session
|
||||
&& !config.environment_switcher.last_session_path.is_absolute()
|
||||
{
|
||||
return Err(Error::Config(
|
||||
"environment_switcher.last_session_path must be absolute".into(),
|
||||
));
|
||||
}
|
||||
for (name, path) in [
|
||||
("main_log_path", &config.main_log_path),
|
||||
("client_log_path", &config.client_log_path),
|
||||
|
||||
@@ -13,5 +13,7 @@ pub mod audit;
|
||||
pub mod config;
|
||||
/// Structured application errors.
|
||||
pub mod error;
|
||||
/// Safe persistence for per-account last-used session preferences.
|
||||
pub mod preferences;
|
||||
/// Session discovery and process lifecycle.
|
||||
pub mod session;
|
||||
|
||||
259
crates/latchd-core/src/preferences.rs
Normal file
259
crates/latchd-core/src/preferences.rs
Normal file
@@ -0,0 +1,259 @@
|
||||
//! Bounded, owner-checked persistence for last-used session choices.
|
||||
|
||||
use std::{
|
||||
fs::{self, OpenOptions},
|
||||
io::Write,
|
||||
os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt},
|
||||
path::Path,
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
error::{Error, ResultExt, io_error},
|
||||
session::{SessionDescriptor, SessionKind},
|
||||
};
|
||||
|
||||
const MAX_ENTRIES: usize = 128;
|
||||
|
||||
/// A previously successful session selection for one account.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct LastUsedEntry {
|
||||
/// NSS username.
|
||||
pub username: String,
|
||||
/// Session family.
|
||||
pub kind: SessionKind,
|
||||
/// Stable discovered session title.
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
/// Bounded last-used session preferences.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct LastUsedPreferences {
|
||||
entries: Vec<LastUsedEntry>,
|
||||
}
|
||||
|
||||
impl LastUsedPreferences {
|
||||
/// Records a successful selection, moving the newest entry to the front.
|
||||
pub fn remember(&mut self, username: &str, session: &SessionDescriptor) {
|
||||
if username.is_empty() || session.title.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.entries.retain(|entry| entry.username != username);
|
||||
self.entries.insert(
|
||||
0,
|
||||
LastUsedEntry {
|
||||
username: username.to_owned(),
|
||||
kind: session.kind,
|
||||
title: session.title.clone(),
|
||||
},
|
||||
);
|
||||
self.entries.truncate(MAX_ENTRIES);
|
||||
}
|
||||
|
||||
/// Finds the exact previously selected session for an account.
|
||||
pub fn preferred_index(&self, username: &str, sessions: &[SessionDescriptor]) -> Option<usize> {
|
||||
let entry = self
|
||||
.entries
|
||||
.iter()
|
||||
.find(|entry| entry.username == username)?;
|
||||
sessions
|
||||
.iter()
|
||||
.position(|session| session.kind == entry.kind && session.title == entry.title)
|
||||
}
|
||||
|
||||
/// Returns persisted entries for diagnostics and tests.
|
||||
pub fn entries(&self) -> &[LastUsedEntry] {
|
||||
&self.entries
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads preferences from an owner-checked TOML file.
|
||||
pub fn load(path: &Path) -> Result<LastUsedPreferences, Error> {
|
||||
if !path.is_absolute() {
|
||||
return Err(Error::Config("last-session path must be absolute".into()));
|
||||
}
|
||||
let metadata = match fs::symlink_metadata(path) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
return Ok(LastUsedPreferences::default());
|
||||
}
|
||||
Err(error) => return Err(io_error(format!("inspect {}", path.display()), error)),
|
||||
};
|
||||
if !trusted_file(&metadata) {
|
||||
return Err(Error::Config(format!(
|
||||
"last-session file must be an owner-only regular file: {}",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
let raw = fs::read_to_string(path).io_context(format!("read {}", path.display()))?;
|
||||
let preferences =
|
||||
toml::from_str::<LastUsedPreferences>(&raw).map_err(|source| Error::Toml {
|
||||
path: path.display().to_string(),
|
||||
source,
|
||||
})?;
|
||||
validate(&preferences, path)?;
|
||||
Ok(preferences)
|
||||
}
|
||||
|
||||
/// Atomically saves preferences with mode `0600` under a trusted directory.
|
||||
pub fn save(path: &Path, preferences: &LastUsedPreferences) -> Result<(), Error> {
|
||||
if !path.is_absolute() {
|
||||
return Err(Error::Config("last-session path must be absolute".into()));
|
||||
}
|
||||
validate(preferences, path)?;
|
||||
let parent = path.parent().ok_or_else(|| {
|
||||
Error::Config(format!(
|
||||
"last-session path has no parent: {}",
|
||||
path.display()
|
||||
))
|
||||
})?;
|
||||
fs::create_dir_all(parent).io_context(format!("create {}", parent.display()))?;
|
||||
let parent_metadata =
|
||||
fs::symlink_metadata(parent).io_context(format!("inspect {}", parent.display()))?;
|
||||
if !trusted_directory(&parent_metadata) {
|
||||
return Err(Error::Config(format!(
|
||||
"last-session directory must be owner-owned and non-writable: {}",
|
||||
parent.display()
|
||||
)));
|
||||
}
|
||||
let content = toml::to_string_pretty(preferences)
|
||||
.map_err(|error| Error::Config(format!("serialize last-session preferences: {error}")))?;
|
||||
let nonce = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_or(0, |duration| duration.as_nanos());
|
||||
let temporary = parent.join(format!(
|
||||
".{}.tmp.{}.{}",
|
||||
path.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or("last-session"),
|
||||
std::process::id(),
|
||||
nonce
|
||||
));
|
||||
let write_result = (|| -> Result<(), Error> {
|
||||
let mut file = OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.mode(0o600)
|
||||
.custom_flags(libc::O_NOFOLLOW)
|
||||
.open(&temporary)
|
||||
.io_context(format!("create {}", temporary.display()))?;
|
||||
file.write_all(content.as_bytes())
|
||||
.io_context(format!("write {}", temporary.display()))?;
|
||||
file.sync_all()
|
||||
.io_context(format!("sync {}", temporary.display()))?;
|
||||
fs::rename(&temporary, path).io_context(format!("replace {}", path.display()))?;
|
||||
fs::set_permissions(path, fs::Permissions::from_mode(0o600))
|
||||
.io_context(format!("secure {}", path.display()))?;
|
||||
Ok(())
|
||||
})();
|
||||
if write_result.is_err() {
|
||||
let _ = fs::remove_file(&temporary);
|
||||
}
|
||||
write_result
|
||||
}
|
||||
|
||||
fn validate(preferences: &LastUsedPreferences, path: &Path) -> Result<(), Error> {
|
||||
if preferences.entries.len() > MAX_ENTRIES {
|
||||
return Err(Error::Config(format!(
|
||||
"last-session file contains more than {MAX_ENTRIES} entries: {}",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
if preferences
|
||||
.entries
|
||||
.iter()
|
||||
.any(|entry| entry.username.is_empty() || entry.title.is_empty())
|
||||
{
|
||||
return Err(Error::Config(format!(
|
||||
"last-session entries must have a username and title: {}",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn trusted_file(metadata: &std::fs::Metadata) -> bool {
|
||||
metadata.is_file() && metadata.uid() == effective_uid() && metadata.mode() & 0o077 == 0
|
||||
}
|
||||
|
||||
fn trusted_directory(metadata: &std::fs::Metadata) -> bool {
|
||||
metadata.is_dir() && metadata.uid() == effective_uid() && metadata.mode() & 0o022 == 0
|
||||
}
|
||||
|
||||
fn effective_uid() -> u32 {
|
||||
// SAFETY: geteuid has no pointer arguments or memory preconditions.
|
||||
unsafe { libc::geteuid() }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn descriptor(kind: SessionKind, title: &str) -> SessionDescriptor {
|
||||
SessionDescriptor {
|
||||
kind,
|
||||
title: title.into(),
|
||||
argv: Vec::new(),
|
||||
current_desktop: None,
|
||||
session_desktop: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn test_path() -> std::path::PathBuf {
|
||||
std::env::temp_dir().join(format!(
|
||||
"latchd-preferences-{}-{}",
|
||||
std::process::id(),
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_or(0, |duration| duration.as_nanos())
|
||||
))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latest_successful_session_is_preferred_per_account() {
|
||||
let wayland = descriptor(SessionKind::Wayland, "Sway");
|
||||
let tty = descriptor(SessionKind::Tty, "TTY shell");
|
||||
let mut preferences = LastUsedPreferences::default();
|
||||
preferences.remember("alice", &wayland);
|
||||
assert_eq!(
|
||||
preferences.preferred_index("alice", &[tty.clone(), wayland]),
|
||||
Some(1)
|
||||
);
|
||||
preferences.remember("alice", &tty);
|
||||
assert_eq!(
|
||||
preferences.preferred_index("alice", &[tty, descriptor(SessionKind::Wayland, "Sway")]),
|
||||
Some(0)
|
||||
);
|
||||
assert_eq!(preferences.entries()[0].username, "alice");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preferences_round_trip_atomically_with_private_permissions() {
|
||||
let directory = test_path();
|
||||
let path = directory.join("last-session.toml");
|
||||
let session = descriptor(SessionKind::X11, "GNOME");
|
||||
let mut preferences = LastUsedPreferences::default();
|
||||
preferences.remember("alice", &session);
|
||||
save(&path, &preferences).expect("preference save should succeed");
|
||||
let loaded = load(&path).expect("preference load should succeed");
|
||||
assert_eq!(loaded, preferences);
|
||||
let mode = fs::symlink_metadata(&path)
|
||||
.expect("preference metadata should exist")
|
||||
.mode();
|
||||
assert_eq!(mode & 0o077, 0);
|
||||
let _ = fs::remove_dir_all(directory);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_preferences_are_a_safe_empty_store() {
|
||||
let path = test_path().join("missing.toml");
|
||||
assert_eq!(
|
||||
load(&path).expect("missing preferences are optional"),
|
||||
LastUsedPreferences::default()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -26,10 +26,12 @@ use crate::{
|
||||
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)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum SessionKind {
|
||||
/// A login shell on the current virtual terminal.
|
||||
Tty,
|
||||
|
||||
Reference in New Issue
Block a user