feat: complete session preferences and PAM fixture gates
This commit is contained in:
@@ -1,8 +1,6 @@
|
||||
//! PAM authentication with one dedicated worker thread.
|
||||
//!
|
||||
//! PAM handles are intentionally never moved out of the worker. The public
|
||||
//! ticket contains only an opaque ID, so the UI cannot accidentally use a PAM
|
||||
//! transaction from the wrong thread or forget to close it.
|
||||
//! The worker owns all PAM handles. Public tickets contain only an opaque ID.
|
||||
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
@@ -35,6 +33,17 @@ const PAM_ERROR_MSG: c_int = 3;
|
||||
const PAM_TEXT_INFO: c_int = 4;
|
||||
const PAM_ESTABLISH_CRED: c_int = 0x0002;
|
||||
const PAM_DELETE_CRED: c_int = 0x0004;
|
||||
const PAM_TTY: c_int = 3;
|
||||
const PAM_CHILD_ENVIRONMENT_KEYS: [&CStr; 8] = [
|
||||
c"XDG_SESSION_CLASS",
|
||||
c"XDG_SESSION_TYPE",
|
||||
c"XDG_SESSION_DESKTOP",
|
||||
c"XDG_SEAT",
|
||||
c"XDG_VTNR",
|
||||
c"XDG_RUNTIME_DIR",
|
||||
c"XDG_SESSION_ID",
|
||||
c"DBUS_SESSION_BUS_ADDRESS",
|
||||
];
|
||||
|
||||
#[repr(C)]
|
||||
struct PamHandle {
|
||||
@@ -83,6 +92,12 @@ unsafe extern "C" {
|
||||
fn pam_acct_mgmt(pamh: *mut PamHandle, flags: c_int) -> c_int;
|
||||
#[link_name = "pam_setcred"]
|
||||
fn pam_setcred(pamh: *mut PamHandle, flags: c_int) -> c_int;
|
||||
#[link_name = "pam_set_item"]
|
||||
fn pam_set_item(pamh: *mut PamHandle, item_type: c_int, item: *const c_void) -> c_int;
|
||||
#[link_name = "pam_putenv"]
|
||||
fn pam_putenv(pamh: *mut PamHandle, name_value: *const c_char) -> c_int;
|
||||
#[link_name = "pam_getenv"]
|
||||
fn pam_getenv(pamh: *mut PamHandle, name: *const c_char) -> *const c_char;
|
||||
#[link_name = "pam_open_session"]
|
||||
fn pam_open_session(pamh: *mut PamHandle, flags: c_int) -> c_int;
|
||||
#[link_name = "pam_close_session"]
|
||||
@@ -101,7 +116,6 @@ pub struct AuthTicket {
|
||||
}
|
||||
|
||||
impl AuthTicket {
|
||||
/// Returns the account identity associated with this ticket.
|
||||
pub fn identity(&self) -> &Identity {
|
||||
&self.identity
|
||||
}
|
||||
@@ -146,7 +160,6 @@ pub struct AuthService {
|
||||
}
|
||||
|
||||
impl AuthService {
|
||||
/// Starts the PAM worker.
|
||||
pub fn new() -> Result<Self, Error> {
|
||||
let (commands, requests) = sync_channel::<Command>(1);
|
||||
thread::Builder::new()
|
||||
@@ -156,7 +169,6 @@ impl AuthService {
|
||||
Ok(Self { commands })
|
||||
}
|
||||
|
||||
/// Starts one authentication transaction without blocking the UI thread.
|
||||
pub fn authenticate(
|
||||
&self,
|
||||
username: String,
|
||||
@@ -182,9 +194,8 @@ impl AuthService {
|
||||
|
||||
/// Changes an expired account password through the configured PAM policy.
|
||||
///
|
||||
/// PAM owns password-strength, history, and token validation. The current
|
||||
/// and replacement passwords remain zeroizing buffers for the entire
|
||||
/// worker transaction and are never returned in an error.
|
||||
/// PAM enforces password strength, history, and token rules.
|
||||
/// Zeroizing buffers hold both passwords and errors never include them.
|
||||
pub fn change_password(
|
||||
&self,
|
||||
username: String,
|
||||
@@ -220,7 +231,6 @@ impl AuthService {
|
||||
.map_err(|_| Error::Worker("PAM worker disconnected".into()))?
|
||||
}
|
||||
|
||||
/// Cancels a ticket that will not be used for a session.
|
||||
pub fn cancel(&self, mut ticket: AuthTicket) -> Result<(), Error> {
|
||||
let id = ticket.id;
|
||||
ticket.owner = None;
|
||||
@@ -312,30 +322,7 @@ fn worker(requests: Receiver<Command>) {
|
||||
}
|
||||
Command::Run { id, spec, response } => {
|
||||
let result = match sessions.remove(&id) {
|
||||
Some(mut session) => {
|
||||
let opened = unsafe { pam_open_session(session.handle, 0) };
|
||||
if opened != PAM_SUCCESS {
|
||||
let error = pam_error(session.handle, "pam_open_session", opened);
|
||||
let _ = session.close(opened);
|
||||
Err(error)
|
||||
} else {
|
||||
session.opened = true;
|
||||
let run = match session.identity.as_ref() {
|
||||
Some(identity) => session::run_user_session(identity, &spec),
|
||||
None => Err(Error::Worker("ticket has no account identity".into())),
|
||||
};
|
||||
let close = session.close(if run.is_ok() {
|
||||
PAM_SUCCESS
|
||||
} else {
|
||||
PAM_SYSTEM_ERR
|
||||
});
|
||||
match (run, close) {
|
||||
(Err(error), _) => Err(error),
|
||||
(Ok(()), Err(error)) => Err(error),
|
||||
(Ok(()), Ok(())) => Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(session) => run_authenticated_session(session, spec),
|
||||
None => Err(Error::Worker("unknown authentication ticket".into())),
|
||||
};
|
||||
let _ = response.send(result);
|
||||
@@ -347,6 +334,86 @@ fn worker(requests: Receiver<Command>) {
|
||||
}
|
||||
}
|
||||
|
||||
fn run_authenticated_session(mut session: PamSession, mut spec: SessionSpec) -> Result<(), Error> {
|
||||
if let Err(error) = configure_pam_session(session.handle, &spec) {
|
||||
let _ = session.close(PAM_SYSTEM_ERR);
|
||||
return Err(error);
|
||||
}
|
||||
// SAFETY: This worker owns the live handle until `PamSession::close`.
|
||||
let opened = unsafe { pam_open_session(session.handle, 0) };
|
||||
if opened != PAM_SUCCESS {
|
||||
let error = pam_error(session.handle, "pam_open_session", opened);
|
||||
let _ = session.close(opened);
|
||||
return Err(error);
|
||||
}
|
||||
session.opened = true;
|
||||
|
||||
let run = apply_pam_environment(session.handle, &mut spec).and_then(|()| {
|
||||
session.identity.as_ref().map_or_else(
|
||||
|| Err(Error::Worker("ticket has no account identity".into())),
|
||||
|identity| session::run_user_session(identity, &spec),
|
||||
)
|
||||
});
|
||||
let close = session.close(if run.is_ok() {
|
||||
PAM_SUCCESS
|
||||
} else {
|
||||
PAM_SYSTEM_ERR
|
||||
});
|
||||
match (run, close) {
|
||||
(Err(error), _) | (Ok(()), Err(error)) => Err(error),
|
||||
(Ok(()), Ok(())) => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn configure_pam_session(handle: *mut PamHandle, spec: &SessionSpec) -> Result<(), Error> {
|
||||
let tty = CString::new(spec.pam_tty())
|
||||
.map_err(|_| Error::Session("PAM terminal name contains NUL".into()))?;
|
||||
// SAFETY: The worker owns the live handle. `tty` is NUL-terminated, and PAM copies the value.
|
||||
let result = unsafe { pam_set_item(handle, PAM_TTY, tty.as_ptr().cast()) };
|
||||
if result != PAM_SUCCESS {
|
||||
return Err(pam_error(handle, "pam_set_item(PAM_TTY)", result));
|
||||
}
|
||||
|
||||
for (key, value) in spec.pam_environment() {
|
||||
let assignment = CString::new(format!("{key}={value}"))
|
||||
.map_err(|_| Error::Session("PAM session environment contains NUL".into()))?;
|
||||
// SAFETY: The worker owns the live handle. PAM copies the NUL-terminated assignment.
|
||||
let result = unsafe { pam_putenv(handle, assignment.as_ptr()) };
|
||||
if result != PAM_SUCCESS {
|
||||
return Err(pam_error(handle, "pam_putenv", result));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_pam_environment(handle: *mut PamHandle, spec: &mut SessionSpec) -> Result<(), Error> {
|
||||
for name in PAM_CHILD_ENVIRONMENT_KEYS {
|
||||
// SAFETY: The worker owns the live handle. Each name is a static C string.
|
||||
let value = unsafe { pam_getenv(handle, name.as_ptr()) };
|
||||
if value.is_null() {
|
||||
continue;
|
||||
}
|
||||
// SAFETY: PAM owns this C string. Copy it before the next PAM call.
|
||||
let value = unsafe { CStr::from_ptr(value) }
|
||||
.to_str()
|
||||
.map_err(|_| Error::Session("PAM session environment is not UTF-8".into()))?
|
||||
.to_owned();
|
||||
let key = name
|
||||
.to_str()
|
||||
.map_err(|_| Error::Session("PAM environment key is not UTF-8".into()))?;
|
||||
if let Some((_, existing)) = spec
|
||||
.environment
|
||||
.iter_mut()
|
||||
.find(|(existing, _)| existing == key)
|
||||
{
|
||||
*existing = value;
|
||||
} else {
|
||||
spec.environment.push((key.to_owned(), value));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn authenticate(
|
||||
username: &str,
|
||||
password: Zeroizing<Vec<u8>>,
|
||||
@@ -378,8 +445,7 @@ fn authenticate(
|
||||
&mut handle,
|
||||
)
|
||||
};
|
||||
// Reconstitute the allocation on every path after pam_start. PAM copies the
|
||||
// conversation structure, but does not own appdata_ptr.
|
||||
// SAFETY: `data` came from `Box::into_raw`. PAM borrows `appdata_ptr`.
|
||||
let conversation = unsafe { Box::from_raw(data) };
|
||||
if started != PAM_SUCCESS {
|
||||
let error = pam_error(handle, "pam_start", started);
|
||||
@@ -431,7 +497,7 @@ fn authenticate(
|
||||
}
|
||||
session.credentials_established = true;
|
||||
|
||||
// Authentication is complete. Do not retain the password for the session.
|
||||
// Erase the password before the PAM session remains open.
|
||||
session.conversation.password.zeroize();
|
||||
let identity = session
|
||||
.identity
|
||||
|
||||
133
crates/latchd-auth/tests/pam_fixture.rs
Normal file
133
crates/latchd-auth/tests/pam_fixture.rs
Normal file
@@ -0,0 +1,133 @@
|
||||
use std::env;
|
||||
|
||||
use latchd_auth::AuthService;
|
||||
use latchd_core::{
|
||||
accounts::Identity,
|
||||
config::Config,
|
||||
error::Error,
|
||||
session::{self, SessionDescriptor, SessionKind},
|
||||
};
|
||||
|
||||
fn fixture_service() -> String {
|
||||
env::var("LATCHD_PAM_FIXTURE_SERVICE")
|
||||
.expect("LATCHD_PAM_FIXTURE_SERVICE must point at the disposable PAM fixture")
|
||||
}
|
||||
|
||||
fn session_fixture_service() -> String {
|
||||
env::var("LATCHD_PAM_SESSION_FIXTURE_SERVICE")
|
||||
.expect("LATCHD_PAM_SESSION_FIXTURE_SERVICE must point at the disposable PAM fixture")
|
||||
}
|
||||
|
||||
fn assert_sanitized_pam_failure(result: Result<(), Error>, secret: &str) {
|
||||
let error = result.expect_err("the fault-injection PAM fixture must reject the operation");
|
||||
match error {
|
||||
Error::Pam {
|
||||
status, message, ..
|
||||
} => {
|
||||
assert_ne!(status, 0);
|
||||
assert!(!message.contains(secret));
|
||||
}
|
||||
_ => panic!("fault-injection fixture returned a non-PAM error"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires the disposable PAM fixture installed by the Docker/VM harness"]
|
||||
fn authentication_fault_is_rejected_without_secret_leakage() {
|
||||
let service = AuthService::new().expect("PAM worker should start");
|
||||
let secret = "fixture-auth-secret";
|
||||
let result = service.authenticate("root".into(), secret.as_bytes().to_vec(), fixture_service());
|
||||
let error = match result {
|
||||
Ok(_) => panic!("pam_deny fixture must reject authentication"),
|
||||
Err(error) => error,
|
||||
};
|
||||
match error {
|
||||
Error::Pam {
|
||||
status, message, ..
|
||||
} => {
|
||||
assert_ne!(status, 0);
|
||||
assert!(!message.contains(secret));
|
||||
}
|
||||
_ => panic!("fault-injection fixture returned a non-PAM error"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires the disposable PAM fixture installed by the Docker/VM harness"]
|
||||
fn password_change_fault_is_rejected_without_secret_leakage() {
|
||||
let service = AuthService::new().expect("PAM worker should start");
|
||||
assert_sanitized_pam_failure(
|
||||
service.change_password(
|
||||
"root".into(),
|
||||
b"fixture-old-secret".to_vec(),
|
||||
b"fixture-new-secret".to_vec(),
|
||||
fixture_service(),
|
||||
),
|
||||
"fixture-new-secret",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires the disposable PAM fixture installed by the Docker/VM harness"]
|
||||
fn selected_session_metadata_reaches_pam_before_open() {
|
||||
let service = AuthService::new().expect("PAM worker should start");
|
||||
let ticket = service
|
||||
.authenticate("root".into(), Vec::new(), session_fixture_service())
|
||||
.expect("permit fixture should authenticate root");
|
||||
let identity = Identity {
|
||||
username: "root".into(),
|
||||
uid: 0,
|
||||
gid: 0,
|
||||
home: "/root".into(),
|
||||
shell: "/bin/sh".into(),
|
||||
};
|
||||
let descriptor = SessionDescriptor {
|
||||
kind: SessionKind::Wayland,
|
||||
title: "Niri fixture".into(),
|
||||
argv: vec!["/bin/true".into()],
|
||||
current_desktop: Some("niri".into()),
|
||||
session_desktop: Some("niri".into()),
|
||||
};
|
||||
let spec = session::build_spec(&identity, &Config::default(), &descriptor)
|
||||
.expect("fixture session should build");
|
||||
|
||||
service
|
||||
.run_session(ticket, spec)
|
||||
.expect("PAM must receive the selected graphical session metadata");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires the disposable PAM fixture installed by the Docker/VM harness"]
|
||||
fn pam_runtime_environment_reaches_the_session_child() {
|
||||
let service = AuthService::new().expect("PAM worker should start");
|
||||
let ticket = service
|
||||
.authenticate("root".into(), Vec::new(), session_fixture_service())
|
||||
.expect("permit fixture should authenticate root");
|
||||
let identity = Identity {
|
||||
username: "root".into(),
|
||||
uid: 0,
|
||||
gid: 0,
|
||||
home: "/root".into(),
|
||||
shell: "/bin/sh".into(),
|
||||
};
|
||||
let descriptor = SessionDescriptor {
|
||||
kind: SessionKind::Wayland,
|
||||
title: "PAM environment fixture".into(),
|
||||
argv: vec![
|
||||
"/bin/sh".into(),
|
||||
"-c".into(),
|
||||
"test \"$XDG_SESSION_ID\" = fixture-session && \
|
||||
test \"$XDG_RUNTIME_DIR\" = /tmp/latchd-runtime-fixture && \
|
||||
test \"$DBUS_SESSION_BUS_ADDRESS\" = unix:path=/tmp/latchd-runtime-fixture/bus"
|
||||
.into(),
|
||||
],
|
||||
current_desktop: Some("niri".into()),
|
||||
session_desktop: Some("niri".into()),
|
||||
};
|
||||
let spec = session::build_spec(&identity, &Config::default(), &descriptor)
|
||||
.expect("fixture session should build");
|
||||
|
||||
service
|
||||
.run_session(ticket, spec)
|
||||
.expect("the child must receive runtime values produced by PAM");
|
||||
}
|
||||
@@ -4,7 +4,6 @@ use std::{io, mem, os::raw::c_char, ptr};
|
||||
|
||||
use crate::error::Error;
|
||||
|
||||
/// A live user-process record in UTMPX.
|
||||
pub struct UtmpxGuard {
|
||||
record: libc::utmpx,
|
||||
closed: bool,
|
||||
|
||||
@@ -14,90 +14,61 @@ use crate::{
|
||||
error::{Error, ResultExt},
|
||||
};
|
||||
|
||||
/// An account that can be offered by the login screen.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Identity {
|
||||
/// Canonical username.
|
||||
pub username: String,
|
||||
/// Numeric user ID.
|
||||
pub uid: u32,
|
||||
/// Primary group ID.
|
||||
pub gid: u32,
|
||||
/// Home directory.
|
||||
pub home: PathBuf,
|
||||
/// Login shell.
|
||||
pub shell: PathBuf,
|
||||
}
|
||||
|
||||
/// Effective account state determined from the login shell and shadow aging data.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AccountState {
|
||||
/// Account can be offered for login.
|
||||
Active,
|
||||
/// Password is disabled by the shadow lock marker.
|
||||
Locked,
|
||||
/// Password must be changed before login can continue.
|
||||
PasswordExpired,
|
||||
/// Account expiration date has elapsed.
|
||||
AccountExpired,
|
||||
/// Password inactivity period has elapsed.
|
||||
AccountInactive,
|
||||
/// Login shell explicitly disallows interactive sessions.
|
||||
NoLoginShell,
|
||||
/// Shadow data was not available or could not be parsed.
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// Non-sensitive password and account aging information.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct AccountStatus {
|
||||
/// Effective state used by the chooser and diagnostics.
|
||||
pub state: AccountState,
|
||||
/// Day the password was last changed, if present.
|
||||
pub password_changed_day: Option<i64>,
|
||||
/// Day the password expires, if the policy specifies one.
|
||||
pub password_expiry_day: Option<i64>,
|
||||
/// Day the account expires, if specified.
|
||||
pub account_expiry_day: Option<i64>,
|
||||
/// Day after which an expired password disables the account.
|
||||
pub inactive_after_day: Option<i64>,
|
||||
}
|
||||
|
||||
impl AccountStatus {
|
||||
/// Returns whether this account may be presented as an active login choice.
|
||||
pub const fn is_login_allowed(self) -> bool {
|
||||
matches!(self.state, AccountState::Active)
|
||||
}
|
||||
}
|
||||
|
||||
/// An identity paired with its non-sensitive account policy state.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AccountRecord {
|
||||
/// Discovered NSS identity.
|
||||
pub identity: Identity,
|
||||
/// Current local account status.
|
||||
pub status: AccountStatus,
|
||||
}
|
||||
|
||||
/// A currently logged-in process obtained from UTMPX.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ActiveSession {
|
||||
/// Login name associated with the session.
|
||||
pub username: String,
|
||||
/// UTMPX line, such as `tty2` or `pts/0`.
|
||||
pub terminal: String,
|
||||
/// Session leader PID when reported by UTMPX.
|
||||
pub pid: libc::pid_t,
|
||||
/// Remote host, if supplied by the session producer.
|
||||
pub host: Option<String>,
|
||||
}
|
||||
|
||||
/// Runtime session-management backend detected on this host.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SessionBackend {
|
||||
/// systemd-logind runtime markers are available.
|
||||
SystemdLogind,
|
||||
/// UTMPX is available as a conservative fallback.
|
||||
Utmpx,
|
||||
}
|
||||
|
||||
@@ -113,15 +84,10 @@ struct ShadowEntry {
|
||||
/// A non-authoritative account lockout summary.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub struct FaillockStatus {
|
||||
/// Whether the faillock helper returned a usable result.
|
||||
pub available: bool,
|
||||
/// Number of observed failed attempts.
|
||||
pub failures: u32,
|
||||
/// Configured failure threshold, when discoverable.
|
||||
pub max_tries: Option<u32>,
|
||||
/// Remaining attempts before the advisory threshold.
|
||||
pub remaining: Option<u32>,
|
||||
/// Whether the advisory threshold has been reached.
|
||||
pub locked_out: bool,
|
||||
}
|
||||
|
||||
@@ -291,12 +257,8 @@ pub fn faillock_status(username: &str, config: &AccountsConfig) -> Result<Faillo
|
||||
Ok(faillock_status_from_output(&output.stdout, max_tries))
|
||||
}
|
||||
|
||||
/// Converts stable helper output into an advisory lockout status.
|
||||
///
|
||||
/// This parser accepts both the legacy `count timestamp` format and modern
|
||||
/// rows containing a username prefix or timestamp columns. It intentionally
|
||||
/// treats malformed output as zero failures instead of making authentication
|
||||
/// fail open or fail closed.
|
||||
/// Parses the legacy count format and current rows with usernames or timestamps.
|
||||
/// Malformed output reports zero failures and does not control authentication.
|
||||
pub fn faillock_status_from_output(output: &[u8], max_tries: Option<u32>) -> FaillockStatus {
|
||||
let failures = parse_failure_count(output);
|
||||
let remaining = max_tries.map(|limit| limit.saturating_sub(failures));
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
//! Redacted security audit events.
|
||||
//!
|
||||
//! Audit records deliberately contain no password, token, PAM message, or
|
||||
//! command-line detail. Callers can send the fields to a structured logger
|
||||
//! without accidentally turning an authentication failure into a secret dump.
|
||||
//! Audit records exclude secrets, PAM messages, and command arguments.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
@@ -11,11 +9,8 @@ use crate::error::Error;
|
||||
/// The security operation that produced an audit event.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AuditAction {
|
||||
/// A PAM authentication attempt.
|
||||
Authentication,
|
||||
/// A PAM password-change transaction.
|
||||
PasswordChange,
|
||||
/// A user session handoff.
|
||||
Session,
|
||||
}
|
||||
|
||||
@@ -32,13 +27,9 @@ impl fmt::Display for AuditAction {
|
||||
/// The outcome of a security operation.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AuditOutcome {
|
||||
/// The operation was requested.
|
||||
Started,
|
||||
/// The operation completed successfully.
|
||||
Succeeded,
|
||||
/// The operation was rejected or failed.
|
||||
Failed,
|
||||
/// PAM requires a password change before proceeding.
|
||||
PasswordChangeRequired,
|
||||
}
|
||||
|
||||
@@ -56,21 +47,13 @@ impl fmt::Display for AuditOutcome {
|
||||
/// A safe category for a PAM failure, without exposing PAM's free-form text.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PamReason {
|
||||
/// Password or other authentication material was rejected.
|
||||
AuthenticationRejected,
|
||||
/// PAM could not reach its backing authentication service.
|
||||
AuthenticationUnavailable,
|
||||
/// PAM reports that the account has expired.
|
||||
AccountExpired,
|
||||
/// PAM requires a password change.
|
||||
PasswordChangeRequired,
|
||||
/// PAM reports that the retry limit was reached.
|
||||
TooManyAttempts,
|
||||
/// PAM conversation or token handling failed.
|
||||
Conversation,
|
||||
/// A PAM/system operation failed.
|
||||
System,
|
||||
/// An unclassified PAM status was returned.
|
||||
Unknown,
|
||||
}
|
||||
|
||||
@@ -92,18 +75,14 @@ impl fmt::Display for PamReason {
|
||||
/// A structured event whose fields are safe to send to an audit logger.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct AuditEvent<'a> {
|
||||
/// Security operation.
|
||||
pub action: AuditAction,
|
||||
/// Operation outcome.
|
||||
pub outcome: AuditOutcome,
|
||||
/// Account name supplied by the user; never a password or token.
|
||||
pub account: &'a str,
|
||||
/// Sanitized PAM failure category, when applicable.
|
||||
pub pam_reason: Option<PamReason>,
|
||||
}
|
||||
|
||||
impl<'a> AuditEvent<'a> {
|
||||
/// Creates an event for an operation that has just started.
|
||||
pub const fn started(action: AuditAction, account: &'a str) -> Self {
|
||||
Self {
|
||||
action,
|
||||
@@ -113,7 +92,6 @@ impl<'a> AuditEvent<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a successful event.
|
||||
pub const fn succeeded(action: AuditAction, account: &'a str) -> Self {
|
||||
Self {
|
||||
action,
|
||||
@@ -123,7 +101,6 @@ impl<'a> AuditEvent<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a failure event with a sanitized PAM category.
|
||||
pub fn failed(action: AuditAction, account: &'a str, error: &Error) -> Self {
|
||||
Self {
|
||||
action,
|
||||
|
||||
@@ -9,33 +9,20 @@ use serde::Deserialize;
|
||||
|
||||
use crate::error::{Error, ResultExt};
|
||||
|
||||
/// Validated application configuration.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct Config {
|
||||
/// Virtual terminal number used by the service.
|
||||
pub tty: u8,
|
||||
/// Main log file.
|
||||
pub main_log_path: PathBuf,
|
||||
/// Per-session log file.
|
||||
pub client_log_path: PathBuf,
|
||||
/// PAM service name.
|
||||
pub pam_service: String,
|
||||
/// Default shell used only when the account has no shell value.
|
||||
pub system_shell: PathBuf,
|
||||
/// Session PATH.
|
||||
pub initial_path: String,
|
||||
/// X11 settings.
|
||||
pub x11: X11Config,
|
||||
/// Wayland settings.
|
||||
pub wayland: WaylandConfig,
|
||||
/// Environment switcher settings.
|
||||
pub environment_switcher: SwitcherConfig,
|
||||
/// Power actions shown by the login screen.
|
||||
pub power_controls: PowerControls,
|
||||
/// Account enumeration and advisory lockout settings.
|
||||
pub accounts: AccountsConfig,
|
||||
/// TUI-only background, animation, and notification settings.
|
||||
pub visual: VisualConfig,
|
||||
}
|
||||
|
||||
@@ -58,21 +45,14 @@ impl Default for Config {
|
||||
}
|
||||
}
|
||||
|
||||
/// Account discovery and faillock settings.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct AccountsConfig {
|
||||
/// Minimum UID shown by automatic account discovery.
|
||||
pub minimum_uid: u32,
|
||||
/// Usernames excluded from the chooser.
|
||||
pub excluded_users: Vec<String>,
|
||||
/// Whether the chooser lists NSS accounts.
|
||||
pub enumerate: bool,
|
||||
/// Absolute faillock helper path.
|
||||
pub faillock_path: PathBuf,
|
||||
/// faillock configuration used to read the deny threshold.
|
||||
pub faillock_config: PathBuf,
|
||||
/// Shadow database used for local password and account aging checks.
|
||||
pub shadow_path: PathBuf,
|
||||
}
|
||||
|
||||
@@ -89,21 +69,14 @@ impl Default for AccountsConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// Visual effects used only by the terminal login screen.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct VisualConfig {
|
||||
/// Optional root-owned image used as a low-contrast terminal wallpaper.
|
||||
pub wallpaper_path: Option<PathBuf>,
|
||||
/// Enable the wallpaper and procedural background layer.
|
||||
pub background_enabled: bool,
|
||||
/// Advance animated background and spinner frames.
|
||||
pub animations: bool,
|
||||
/// Show transient in-TUI notifications for user-visible events.
|
||||
pub notifications: bool,
|
||||
/// Disable motion while retaining static visual styling.
|
||||
pub reduced_motion: bool,
|
||||
/// Maximum wallpaper file size accepted before decoding.
|
||||
pub max_wallpaper_bytes: u64,
|
||||
}
|
||||
|
||||
@@ -120,23 +93,15 @@ impl Default for VisualConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// X11 session configuration.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct X11Config {
|
||||
/// X display name.
|
||||
pub display: String,
|
||||
/// X server executable.
|
||||
pub server_path: PathBuf,
|
||||
/// Xauthority executable.
|
||||
pub xauth_path: PathBuf,
|
||||
/// X server startup timeout.
|
||||
pub server_timeout_secs: u16,
|
||||
/// X server log.
|
||||
pub server_log_path: PathBuf,
|
||||
/// Desktop-entry directory.
|
||||
pub sessions_path: PathBuf,
|
||||
/// Custom script directory.
|
||||
pub scripts_path: PathBuf,
|
||||
}
|
||||
|
||||
@@ -154,13 +119,10 @@ impl Default for X11Config {
|
||||
}
|
||||
}
|
||||
|
||||
/// Wayland session configuration.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct WaylandConfig {
|
||||
/// Desktop-entry directory.
|
||||
pub sessions_path: PathBuf,
|
||||
/// Custom script directory.
|
||||
pub scripts_path: PathBuf,
|
||||
}
|
||||
|
||||
@@ -173,14 +135,13 @@ impl Default for WaylandConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// Session switcher settings.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct SwitcherConfig {
|
||||
/// Include the fallback TTY shell in the chooser.
|
||||
pub include_tty_shell: bool,
|
||||
/// Maximum visible title width.
|
||||
pub max_display_length: usize,
|
||||
pub remember_last_session: bool,
|
||||
pub last_session_path: PathBuf,
|
||||
}
|
||||
|
||||
impl Default for SwitcherConfig {
|
||||
@@ -188,29 +149,24 @@ 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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Power actions available to the login screen.
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct PowerControls {
|
||||
/// Built-in or administrator-defined actions.
|
||||
pub entries: Vec<PowerAction>,
|
||||
}
|
||||
|
||||
/// A power action represented as an executable argv vector.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PowerAction {
|
||||
/// Displayed key, such as `F1`.
|
||||
pub key: String,
|
||||
/// Displayed label.
|
||||
pub hint: String,
|
||||
/// Executable path.
|
||||
pub program: PathBuf,
|
||||
/// Arguments passed without shell interpretation.
|
||||
#[serde(default)]
|
||||
pub args: Vec<String>,
|
||||
}
|
||||
@@ -287,6 +243,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),
|
||||
|
||||
@@ -2,47 +2,32 @@ use std::io;
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
/// The application-wide error type.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum Error {
|
||||
/// A command-line or configuration value was invalid.
|
||||
#[error("configuration: {0}")]
|
||||
Config(String),
|
||||
/// A filesystem operation failed.
|
||||
#[error("{context}: {source}")]
|
||||
Io {
|
||||
/// The operation being performed.
|
||||
context: String,
|
||||
/// The underlying error.
|
||||
#[source]
|
||||
source: io::Error,
|
||||
},
|
||||
/// TOML could not be decoded.
|
||||
#[error("parse {path}: {source}")]
|
||||
Toml {
|
||||
/// The file being parsed.
|
||||
path: String,
|
||||
/// The parser error.
|
||||
#[source]
|
||||
source: toml::de::Error,
|
||||
},
|
||||
/// PAM rejected or could not complete an operation.
|
||||
#[error("PAM {operation} failed with status {status}: {message}")]
|
||||
Pam {
|
||||
/// The PAM operation.
|
||||
operation: &'static str,
|
||||
/// The numeric PAM status.
|
||||
status: i32,
|
||||
/// The human-readable PAM message.
|
||||
message: String,
|
||||
},
|
||||
/// A session process failed.
|
||||
#[error("session: {0}")]
|
||||
Session(String),
|
||||
/// The terminal could not be initialized or restored.
|
||||
#[error("terminal: {0}")]
|
||||
Terminal(String),
|
||||
/// An authentication worker request was cancelled or disconnected.
|
||||
#[error("authentication worker: {0}")]
|
||||
Worker(String),
|
||||
}
|
||||
@@ -62,7 +47,6 @@ impl Error {
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds a stable operation label to an I/O error.
|
||||
pub fn io_error(context: impl Into<String>, source: io::Error) -> Error {
|
||||
Error::Io {
|
||||
context: context.into(),
|
||||
@@ -70,9 +54,7 @@ pub fn io_error(context: impl Into<String>, source: io::Error) -> Error {
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts a failed result into an application error with context.
|
||||
pub trait ResultExt<T> {
|
||||
/// Adds context to an I/O operation.
|
||||
fn io_context(self, context: impl Into<String>) -> Result<T, Error>;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
//! Core configuration, account, session, and accounting contracts for latchd.
|
||||
|
||||
#![deny(unsafe_op_in_unsafe_fn)]
|
||||
#![warn(missing_docs)]
|
||||
|
||||
/// Linux UTMPX accounting primitives.
|
||||
pub mod accounting;
|
||||
/// NSS account discovery and faillock status.
|
||||
pub mod accounts;
|
||||
/// Redacted, structured security audit events.
|
||||
pub mod audit;
|
||||
/// Validated application configuration.
|
||||
pub mod config;
|
||||
/// Structured application errors.
|
||||
pub mod error;
|
||||
/// Session discovery and process lifecycle.
|
||||
pub mod preferences;
|
||||
pub mod session;
|
||||
|
||||
253
crates/latchd-core/src/preferences.rs
Normal file
253
crates/latchd-core/src/preferences.rs
Normal file
@@ -0,0 +1,253 @@
|
||||
//! 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;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct LastUsedEntry {
|
||||
pub username: String,
|
||||
pub kind: SessionKind,
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
#[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)
|
||||
}
|
||||
|
||||
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,72 +26,71 @@ 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,
|
||||
/// 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,
|
||||
}
|
||||
|
||||
const PAM_SESSION_ENVIRONMENT_KEYS: [&str; 5] = [
|
||||
"XDG_SESSION_CLASS",
|
||||
"XDG_SESSION_TYPE",
|
||||
"XDG_SESSION_DESKTOP",
|
||||
"XDG_SEAT",
|
||||
"XDG_VTNR",
|
||||
];
|
||||
|
||||
impl SessionSpec {
|
||||
pub fn pam_tty(&self) -> String {
|
||||
format!("tty{}", self.tty)
|
||||
}
|
||||
|
||||
/// Returns the selected-session metadata that PAM and logind must see.
|
||||
pub fn pam_environment(&self) -> impl Iterator<Item = (&str, &str)> {
|
||||
self.environment
|
||||
.iter()
|
||||
.filter(|(key, _)| PAM_SESSION_ENVIRONMENT_KEYS.contains(&key.as_str()))
|
||||
.map(|(key, value)| (key.as_str(), value.as_str()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Discovers configured sessions in stable directory order.
|
||||
pub fn discover(config: &Config) -> Vec<SessionDescriptor> {
|
||||
let mut sessions = Vec::new();
|
||||
@@ -236,7 +235,6 @@ pub fn build_spec(
|
||||
"XDG_RUNTIME_DIR",
|
||||
format!("/run/user/{}", identity.uid),
|
||||
);
|
||||
insert_env(&mut environment, "XDG_SESSION_ID", "latchd".into());
|
||||
insert_env(
|
||||
&mut environment,
|
||||
"XDG_CONFIG_HOME",
|
||||
@@ -858,4 +856,40 @@ mod tests {
|
||||
.all(|(key, _)| key != "DISPLAY" && key != "XAUTHORITY")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_wayland_metadata_is_ready_for_pam() {
|
||||
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: "Niri".into(),
|
||||
argv: vec!["/usr/bin/niri-session".into()],
|
||||
current_desktop: Some("niri".into()),
|
||||
session_desktop: Some("niri".into()),
|
||||
};
|
||||
|
||||
let spec = build_spec(&identity, &Config::default(), &descriptor).expect("valid spec");
|
||||
let pam_environment = spec
|
||||
.pam_environment()
|
||||
.collect::<std::collections::HashMap<_, _>>();
|
||||
|
||||
assert_eq!(spec.pam_tty(), "tty2");
|
||||
assert_eq!(pam_environment.get("XDG_SESSION_CLASS"), Some(&"user"));
|
||||
assert_eq!(pam_environment.get("XDG_SESSION_TYPE"), Some(&"wayland"));
|
||||
assert_eq!(pam_environment.get("XDG_SESSION_DESKTOP"), Some(&"niri"));
|
||||
assert_eq!(pam_environment.get("XDG_SEAT"), Some(&"seat0"));
|
||||
assert_eq!(pam_environment.get("XDG_VTNR"), Some(&"2"));
|
||||
assert!(
|
||||
spec.environment
|
||||
.iter()
|
||||
.all(|(key, _)| key != "XDG_SESSION_ID"),
|
||||
"logind, not latchd, owns the session identifier"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@ const MAX_ANIMATION_WIDTH: usize = 160;
|
||||
const MAX_ANIMATION_HEIGHT: usize = 64;
|
||||
const FRAME_INTERVAL: Duration = Duration::from_millis(120);
|
||||
|
||||
/// Background state owned by the TUI thread.
|
||||
pub(crate) struct Background {
|
||||
wallpaper: Option<RgbImage>,
|
||||
automata: Automata,
|
||||
@@ -34,7 +33,6 @@ pub(crate) struct Background {
|
||||
}
|
||||
|
||||
impl Background {
|
||||
/// Loads a bounded wallpaper image and prepares the procedural layer.
|
||||
pub(crate) fn load(config: &VisualConfig) -> Result<Self, Error> {
|
||||
let wallpaper = config
|
||||
.wallpaper_path
|
||||
@@ -191,8 +189,7 @@ struct Automata {
|
||||
|
||||
impl Automata {
|
||||
fn resize(&mut self, width: usize, height: usize) {
|
||||
// Keep procedural state bounded even when a terminal reports an
|
||||
// unusually large window. Rendering remains clipped to this layer.
|
||||
// Cap the state size. Some terminal emulators report extreme dimensions.
|
||||
let width = width.min(MAX_ANIMATION_WIDTH);
|
||||
let height = height.min(MAX_ANIMATION_HEIGHT);
|
||||
if self.width == width && self.height == height {
|
||||
|
||||
@@ -21,6 +21,7 @@ use latchd_core::{
|
||||
audit::{AuditAction, AuditEvent},
|
||||
config::Config,
|
||||
error::{Error, ResultExt},
|
||||
preferences::{self, LastUsedPreferences},
|
||||
session::{self, SessionDescriptor},
|
||||
};
|
||||
|
||||
@@ -41,11 +42,8 @@ fn unknown_account_status() -> AccountStatus {
|
||||
}
|
||||
}
|
||||
|
||||
/// The result of one completed login-screen interaction.
|
||||
pub struct LoginSelection {
|
||||
/// Authenticated PAM ticket.
|
||||
pub ticket: AuthTicket,
|
||||
/// Selected session descriptor.
|
||||
pub session: SessionDescriptor,
|
||||
}
|
||||
|
||||
@@ -130,6 +128,8 @@ struct App {
|
||||
refresh_receiver: Option<Receiver<DiscoverySnapshot>>,
|
||||
refreshing: bool,
|
||||
high_contrast: bool,
|
||||
preferences: LastUsedPreferences,
|
||||
preference_path: Option<std::path::PathBuf>,
|
||||
}
|
||||
|
||||
impl App {
|
||||
@@ -181,6 +181,8 @@ impl App {
|
||||
refresh_receiver: None,
|
||||
refreshing: false,
|
||||
high_contrast: std::env::var_os("LATCHD_HIGH_CONTRAST").is_some(),
|
||||
preferences: LastUsedPreferences::default(),
|
||||
preference_path: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,6 +260,7 @@ impl App {
|
||||
.get(self.selected)
|
||||
.cloned()
|
||||
.ok_or_else(|| Error::Session("no session selected".into()))?;
|
||||
self.remember_selected_session(&selected);
|
||||
Ok(Some(LoginSelection {
|
||||
ticket,
|
||||
session: selected,
|
||||
@@ -334,6 +337,25 @@ impl App {
|
||||
);
|
||||
}
|
||||
|
||||
fn apply_preferred_session(&mut self) {
|
||||
if let Some(index) = self
|
||||
.preferences
|
||||
.preferred_index(&self.username, &self.sessions)
|
||||
{
|
||||
self.selected = index;
|
||||
}
|
||||
}
|
||||
|
||||
fn remember_selected_session(&mut self, session: &SessionDescriptor) {
|
||||
self.preferences.remember(&self.username, session);
|
||||
let Some(path) = self.preference_path.clone() else {
|
||||
return;
|
||||
};
|
||||
if let Err(error) = preferences::save(&path, &self.preferences) {
|
||||
tracing::debug!(%error, "could not persist last-session preference");
|
||||
}
|
||||
}
|
||||
|
||||
fn poll_faillock(&mut self) {
|
||||
let Some(receiver) = &self.faillock_receiver else {
|
||||
return;
|
||||
@@ -995,6 +1017,7 @@ impl App {
|
||||
self.auth_blocked_until = None;
|
||||
self.faillock_status = None;
|
||||
self.status = format!("Selected account {}", self.username);
|
||||
self.apply_preferred_session();
|
||||
self.start_faillock_query();
|
||||
self.notify(
|
||||
&format!("Selected account {}", self.username),
|
||||
@@ -1368,9 +1391,25 @@ pub fn run(
|
||||
(Vec::new(), Vec::new())
|
||||
};
|
||||
let background = Background::load(&config.visual)?;
|
||||
let preference_path = config
|
||||
.environment_switcher
|
||||
.remember_last_session
|
||||
.then(|| config.environment_switcher.last_session_path.clone());
|
||||
let preferences =
|
||||
preference_path
|
||||
.as_deref()
|
||||
.map_or_else(LastUsedPreferences::default, |path| {
|
||||
preferences::load(path).unwrap_or_else(|error| {
|
||||
tracing::debug!(%error, "could not load last-session preferences");
|
||||
LastUsedPreferences::default()
|
||||
})
|
||||
});
|
||||
let mut app = App::new(config, auth, preview, sessions, accounts, background);
|
||||
app.account_statuses = account_statuses;
|
||||
app.account_index = (!app.accounts.is_empty()).then_some(0);
|
||||
app.preferences = preferences;
|
||||
app.preference_path = preference_path;
|
||||
app.apply_preferred_session();
|
||||
app.start_faillock_query();
|
||||
ratatui::run(|terminal| app.run(terminal)).map_err(|error| Error::Terminal(error.to_string()))
|
||||
}
|
||||
@@ -1654,6 +1693,54 @@ mod tests {
|
||||
assert!(rendered.contains("New password"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_session_preference_follows_account_and_persists_success() {
|
||||
let auth = AuthService::new().expect("PAM worker should start");
|
||||
let sessions = vec![
|
||||
SessionDescriptor {
|
||||
kind: session::SessionKind::Tty,
|
||||
title: "TTY shell".into(),
|
||||
argv: Vec::new(),
|
||||
current_desktop: None,
|
||||
session_desktop: None,
|
||||
},
|
||||
SessionDescriptor {
|
||||
kind: session::SessionKind::Wayland,
|
||||
title: "Sway".into(),
|
||||
argv: vec!["sway".into()],
|
||||
current_desktop: None,
|
||||
session_desktop: None,
|
||||
},
|
||||
];
|
||||
let mut app = App::new(
|
||||
Config::default(),
|
||||
auth,
|
||||
true,
|
||||
sessions,
|
||||
vec![Identity {
|
||||
username: "alice".into(),
|
||||
uid: 1000,
|
||||
gid: 1000,
|
||||
home: "/home/alice".into(),
|
||||
shell: "/bin/sh".into(),
|
||||
}],
|
||||
Background::load(&Config::default().visual).expect("default background should load"),
|
||||
);
|
||||
app.preferences.remember("alice", &app.sessions[1]);
|
||||
app.apply_preferred_session();
|
||||
assert_eq!(app.selected, 1);
|
||||
|
||||
let directory =
|
||||
std::env::temp_dir().join(format!("latchd-tui-preferences-{}", std::process::id()));
|
||||
app.preference_path = Some(directory.join("last-session.toml"));
|
||||
let selected = app.sessions[1].clone();
|
||||
app.remember_selected_session(&selected);
|
||||
let loaded = preferences::load(&directory.join("last-session.toml"))
|
||||
.expect("selected session should persist");
|
||||
assert_eq!(loaded.preferred_index("alice", &app.sessions), Some(1));
|
||||
let _ = std::fs::remove_dir_all(directory);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filtering_is_smart_case_and_selects_a_matching_session() {
|
||||
let auth = AuthService::new().expect("PAM worker should start");
|
||||
|
||||
Reference in New Issue
Block a user