From ee6186ed0bc7b581c09ff68e40ba05f305d50653 Mon Sep 17 00:00:00 2001 From: Zane Walker Date: Sat, 26 Sep 2026 02:44:29 +0530 Subject: [PATCH] feat: complete session preferences and PAM fixture gates --- CHANGELOG.md | 18 +- Dockerfile.test | 4 +- Makefile | 5 +- README.md | 7 +- crates/latchd-auth/src/lib.rs | 140 +++++++++---- crates/latchd-auth/tests/pam_fixture.rs | 133 +++++++++++++ crates/latchd-core/src/accounting.rs | 1 - crates/latchd-core/src/accounts.rs | 42 +--- crates/latchd-core/src/audit.rs | 25 +-- crates/latchd-core/src/config.rs | 59 ++---- crates/latchd-core/src/error.rs | 18 -- crates/latchd-core/src/lib.rs | 8 +- crates/latchd-core/src/preferences.rs | 253 ++++++++++++++++++++++++ crates/latchd-core/src/session.rs | 88 ++++++--- crates/latchd-tui/src/background.rs | 5 +- crates/latchd-tui/src/lib.rs | 93 ++++++++- docs/e2e-testing.md | 5 + extra/config.toml | 8 +- legacy/go/auth/faillock.go | 4 - legacy/go/auth/pam.go | 11 +- legacy/go/auth/utmpx.go | 3 - legacy/go/auth/utmpx_time.h | 5 +- legacy/go/config/config.go | 5 - legacy/go/login/login.go | 32 --- legacy/go/main.go | 6 - legacy/go/session/env.go | 3 - legacy/go/session/exec.go | 3 - legacy/go/session/scan.go | 4 - legacy/go/session/wayland.go | 3 - legacy/go/session/x11.go | 5 +- legacy/go/tui/bg/automata.go | 12 +- scripts/test-cli.sh | 6 +- scripts/test-pam-fixture.sh | 93 +++++++++ scripts/test-system-e2e.sh | 21 +- 34 files changed, 801 insertions(+), 327 deletions(-) create mode 100644 crates/latchd-auth/tests/pam_fixture.rs create mode 100644 crates/latchd-core/src/preferences.rs create mode 100755 scripts/test-pam-fixture.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index ddcf55a..b5e21c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## Unreleased + +- Fixed Wayland compositors launched through user services, including Niri, by + providing the selected session type, desktop, seat, and VT to PAM before + `pam_open_session`. +- Session children now receive allowlisted runtime values produced by PAM, + including the real logind session ID, runtime directory, and user D-Bus + address. Latchd no longer fabricates `XDG_SESSION_ID=latchd`. +- Extended the disposable PAM fixture to verify both sides of the handoff: + selected session metadata entering PAM and PAM-generated values reaching the + session child. + ## 0.5.0 — PAM password policy flow - Added PAM password-change handling for accounts that return @@ -11,8 +23,12 @@ current-password/new-password prompt sequence. - Added redacted structured audit events for authentication, password changes, and session handoff. Raw PAM messages and password material are excluded. -- Added TUI and core policy-transition tests; the workspace now contains 33 +- Added TUI, core, and preference tests; the workspace now contains 37 Rust unit tests. +- Added bounded, atomic last-session preferences per account with owner-only + file permissions and a safe fallback when the state file is unavailable. +- Added disposable PAM deny-policy fault-injection tests. Docker runs them as + root; ordinary host runs skip them without touching the host PAM policy. ## 0.4.0 — Advanced login workflow diff --git a/Dockerfile.test b/Dockerfile.test index 4f543f7..2ffa0d5 100644 --- a/Dockerfile.test +++ b/Dockerfile.test @@ -21,4 +21,6 @@ RUN cargo fmt --all -- --check \ && RUSTDOCFLAGS="-D warnings" cargo doc --locked --workspace --no-deps \ && cargo build --locked --release \ && ./scripts/test-cli.sh target/release/latchd \ - && ./scripts/test-system-e2e.sh target/release/latchd + && ./scripts/test-system-e2e.sh target/release/latchd \ + && install -m 0664 /dev/null /run/utmp \ + && ./scripts/test-pam-fixture.sh diff --git a/Makefile b/Makefile index 322d4bf..d19bc02 100644 --- a/Makefile +++ b/Makefile @@ -48,7 +48,7 @@ RED := endif .PHONY: all deps build release-build test check fmt lint doc audit docker-test \ - integration-test system-e2e system-e2e-strict \ + integration-test pam-fixture-test system-e2e system-e2e-strict \ package release release-check version install uninstall clean run-preview \ legacy-build legacy-check enable update help @@ -98,6 +98,9 @@ docker-test: ## Run the pinned Docker verification suite integration-test: build ## Exercise safe, non-authenticating CLI paths @./scripts/test-cli.sh '$(BUILD_DIR)/$(BINARY)' +pam-fixture-test: ## Run the disposable PAM fault-injection tests when privileged + @./scripts/test-pam-fixture.sh + system-e2e: ## Probe PAM, VT, X11, Wayland, and logind without requiring privileges @./scripts/test-system-e2e.sh '$(BUILD_DIR)/$(BINARY)' diff --git a/README.md b/README.md index 4a9c568..b2fa04d 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,11 @@ Latchd is written in Rust. The old Go prototype is kept in `legacy/go/` for refe - Shows password, account-expiry, locked-account, and faillock status without replacing PAM's decision. - Runs PAM password changes for accounts that require a new password before login. - Lets you switch users and sessions without leaving the login screen. +- Remembers each account's last successful session choice in a root-owned state file. - Finds X11 and Wayland desktop entries and can include a TTY shell. - Starts sessions with a cleared, explicit environment and drops privileges before running the user process. +- Registers the selected desktop, session type, seat, and VT with PAM/logind, + then imports only approved PAM runtime values into the user session. - Records user sessions with UTMPX when the platform provides it. - Provides a Ratatui TUI with filtering, keyboard help, refresh, notifications, wallpaper, and reduced-motion support. - Runs safely in preview mode for UI work and configuration checks. @@ -72,7 +75,8 @@ The complete example is in [`extra/config.toml`](extra/config.toml). Important s - `[accounts]` controls NSS discovery, the faillock helper, faillock policy, and `/etc/shadow` account-age checks. - `[x11]` and `[wayland]` select desktop-entry and administrator script directories. -- `[environment_switcher]` controls session choices and the optional TTY shell. +- `[environment_switcher]` controls session choices, the optional TTY shell, + and the owner-checked last-session preference file. - `[visual]` controls wallpaper size, animation, notifications, and reduced motion. - `[power_controls]` contains absolute program paths and argv arrays. Commands are never passed through a shell. @@ -107,6 +111,7 @@ make check make audit make docker-test make integration-test +make pam-fixture-test # runs when root is available; otherwise reports SKIP make system-e2e ``` diff --git a/crates/latchd-auth/src/lib.rs b/crates/latchd-auth/src/lib.rs index 68ea0e4..8d404ca 100644 --- a/crates/latchd-auth/src/lib.rs +++ b/crates/latchd-auth/src/lib.rs @@ -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 { let (commands, requests) = sync_channel::(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::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) { } } +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>, @@ -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 diff --git a/crates/latchd-auth/tests/pam_fixture.rs b/crates/latchd-auth/tests/pam_fixture.rs new file mode 100644 index 0000000..99b7268 --- /dev/null +++ b/crates/latchd-auth/tests/pam_fixture.rs @@ -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"); +} diff --git a/crates/latchd-core/src/accounting.rs b/crates/latchd-core/src/accounting.rs index d1392b4..3de5014 100644 --- a/crates/latchd-core/src/accounting.rs +++ b/crates/latchd-core/src/accounting.rs @@ -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, diff --git a/crates/latchd-core/src/accounts.rs b/crates/latchd-core/src/accounts.rs index b39cf13..1a5b87c 100644 --- a/crates/latchd-core/src/accounts.rs +++ b/crates/latchd-core/src/accounts.rs @@ -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, - /// Day the password expires, if the policy specifies one. pub password_expiry_day: Option, - /// Day the account expires, if specified. pub account_expiry_day: Option, - /// Day after which an expired password disables the account. pub inactive_after_day: Option, } 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, } /// 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, - /// Remaining attempts before the advisory threshold. pub remaining: Option, - /// Whether the advisory threshold has been reached. pub locked_out: bool, } @@ -291,12 +257,8 @@ pub fn faillock_status(username: &str, config: &AccountsConfig) -> Result) -> FaillockStatus { let failures = parse_failure_count(output); let remaining = max_tries.map(|limit| limit.saturating_sub(failures)); diff --git a/crates/latchd-core/src/audit.rs b/crates/latchd-core/src/audit.rs index 6b1f0b1..6856d4b 100644 --- a/crates/latchd-core/src/audit.rs +++ b/crates/latchd-core/src/audit.rs @@ -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, } 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, diff --git a/crates/latchd-core/src/config.rs b/crates/latchd-core/src/config.rs index e862bf0..a59da33 100644 --- a/crates/latchd-core/src/config.rs +++ b/crates/latchd-core/src/config.rs @@ -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, - /// 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, - /// 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, } -/// 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, } @@ -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), diff --git a/crates/latchd-core/src/error.rs b/crates/latchd-core/src/error.rs index a176d3c..008e5f3 100644 --- a/crates/latchd-core/src/error.rs +++ b/crates/latchd-core/src/error.rs @@ -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, source: io::Error) -> Error { Error::Io { context: context.into(), @@ -70,9 +54,7 @@ pub fn io_error(context: impl Into, source: io::Error) -> Error { } } -/// Converts a failed result into an application error with context. pub trait ResultExt { - /// Adds context to an I/O operation. fn io_context(self, context: impl Into) -> Result; } diff --git a/crates/latchd-core/src/lib.rs b/crates/latchd-core/src/lib.rs index 863fd11..67f618e 100644 --- a/crates/latchd-core/src/lib.rs +++ b/crates/latchd-core/src/lib.rs @@ -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; diff --git a/crates/latchd-core/src/preferences.rs b/crates/latchd-core/src/preferences.rs new file mode 100644 index 0000000..422276f --- /dev/null +++ b/crates/latchd-core/src/preferences.rs @@ -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, +} + +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 { + 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 { + 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::(&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() + ); + } +} diff --git a/crates/latchd-core/src/session.rs b/crates/latchd-core/src/session.rs index eee9f6a..c9d24e7 100644 --- a/crates/latchd-core/src/session.rs +++ b/crates/latchd-core/src/session.rs @@ -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, - /// XDG current-desktop value. pub current_desktop: Option, - /// XDG session-desktop value. pub session_desktop: Option, } -/// A fully materialized session request. #[derive(Debug, Clone)] pub struct SessionSpec { - /// Session family. pub kind: SessionKind, - /// Session title for logs. pub title: String, - /// Program and arguments. pub argv: Vec, - /// Explicit child environment. pub environment: Vec<(String, String)>, - /// Working directory. pub working_directory: PathBuf, - /// Private stdout/stderr log for graphical sessions. pub client_log_path: PathBuf, - /// Login shell for TTY fallback. pub shell: PathBuf, - /// Virtual terminal number. pub tty: u8, - /// X11 configuration when this is an X11 session. pub x11: Option, } -/// Runtime X11 parameters passed to a session. #[derive(Debug, Clone)] pub struct X11Runtime { - /// X display name. pub display: String, - /// X server command. pub server_path: PathBuf, - /// Xauthority command. pub xauth_path: PathBuf, - /// X server timeout. pub timeout: Duration, - /// X server log path. pub log_path: PathBuf, } +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 { + 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 { 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::>(); + + 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" + ); + } } diff --git a/crates/latchd-tui/src/background.rs b/crates/latchd-tui/src/background.rs index 7a26cc6..19a254d 100644 --- a/crates/latchd-tui/src/background.rs +++ b/crates/latchd-tui/src/background.rs @@ -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, 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 { 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 { diff --git a/crates/latchd-tui/src/lib.rs b/crates/latchd-tui/src/lib.rs index 9840a7f..d7b7ed8 100644 --- a/crates/latchd-tui/src/lib.rs +++ b/crates/latchd-tui/src/lib.rs @@ -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>, refreshing: bool, high_contrast: bool, + preferences: LastUsedPreferences, + preference_path: Option, } 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"); diff --git a/docs/e2e-testing.md b/docs/e2e-testing.md index 52e5051..cf2eb47 100644 --- a/docs/e2e-testing.md +++ b/docs/e2e-testing.md @@ -5,6 +5,10 @@ Latchd has two test layers: * `make integration-test` builds the binary and exercises metadata, TOML validation, preflight, and the non-authenticating preview path. It does not need PAM credentials, root, a VT, X11, or Wayland. +* `make pam-fixture-test` installs unique temporary PAM services only when run + as root, executes authentication/password-change fault tests and the session + metadata/environment handoff tests, then removes the services on exit. + Non-root runs report `SKIP`. * `make system-e2e` probes the host for PAM, faillock, VT, X11, Wayland, and systemd-logind capabilities. Missing capabilities are reported as `SKIP`, never as a successful test. Use `make system-e2e-strict` only in a prepared @@ -22,6 +26,7 @@ For a local release-style run: ```sh make integration-test +make pam-fixture-test make docker-test make system-e2e ``` diff --git a/extra/config.toml b/extra/config.toml index 3212ffb..afeff03 100644 --- a/extra/config.toml +++ b/extra/config.toml @@ -1,5 +1,3 @@ -# latchd configuration. All paths are validated before the login screen starts. - tty = 2 main_log_path = "/var/log/latchd.log" client_log_path = "/var/log/latchd.client.log" @@ -23,10 +21,10 @@ scripts_path = "/etc/latchd/wayland" [environment_switcher] include_tty_shell = false max_display_length = 18 +remember_last_session = true +last_session_path = "/var/lib/latchd/last-session.toml" -# Power actions use an executable path and argv, never a shell command string. -# Keep this section empty unless the installation explicitly wants console -# power controls. +# Power actions use an executable and arguments. Latchd does not use a shell. [power_controls] entries = [] diff --git a/legacy/go/auth/faillock.go b/legacy/go/auth/faillock.go index 798a729..442d2e4 100644 --- a/legacy/go/auth/faillock.go +++ b/legacy/go/auth/faillock.go @@ -1,7 +1,3 @@ -// auth — faillock integration. -// -// Reads /etc/security/faillock.conf for deny count, executes -// faillock --user to get tally, supports --reset via admin bypass. package auth import ( diff --git a/legacy/go/auth/pam.go b/legacy/go/auth/pam.go index 789388a..01e7b81 100644 --- a/legacy/go/auth/pam.go +++ b/legacy/go/auth/pam.go @@ -1,9 +1,4 @@ -// auth — PAM authentication with CGO bindings. -// -// Uses runtime.LockOSThread to prevent goroutine migration during -// PAM transactions, as PAM modules rely on thread-local storage. -// -// Mirrors vendor/src/auth/pam.rs and vendor/src/auth/mod.rs. +// Keep each PAM transaction on one OS thread. PAM modules can use thread-local state. package auth /* @@ -12,11 +7,11 @@ package auth #include #include -// Match the //export-generated signature. +// Match the signature that cgo generates for //export. extern int latchd_pam_conv(int, struct pam_message **, struct pam_response **, void *); -// Store a uintptr_t handle in C heap so the Go GC never sees a Go pointer. +// Store the numeric handle in C memory. Do not pass a Go pointer to PAM. static void *storeHandle(uintptr_t h) { uintptr_t *p = malloc(sizeof(uintptr_t)); if (p) *p = h; diff --git a/legacy/go/auth/utmpx.go b/legacy/go/auth/utmpx.go index d9dc146..e92334d 100644 --- a/legacy/go/auth/utmpx.go +++ b/legacy/go/auth/utmpx.go @@ -1,6 +1,3 @@ -// auth — UTMPX user accounting (glibc only). -// -// Mirrors vendor/src/auth/utmpx.rs. package auth /* diff --git a/legacy/go/auth/utmpx_time.h b/legacy/go/auth/utmpx_time.h index fe05439..812a0c3 100644 --- a/legacy/go/auth/utmpx_time.h +++ b/legacy/go/auth/utmpx_time.h @@ -1,7 +1,4 @@ -// Helper: assign seconds + microseconds to utmpx's ut_tv field. -// The C compiler handles the type conversion, so this works whether -// ut_tv is struct timeval (long tv_sec/tv_usec) or the compat struct -// (__uint32_t tv_sec / __int32_t tv_usec). +// Let the C compiler convert values for either native or compatibility ut_tv fields. #include static inline void latchd_set_utmpx_time(struct utmpx *ut, int64_t sec, int32_t usec) { diff --git a/legacy/go/config/config.go b/legacy/go/config/config.go index 4167add..5f57fc8 100644 --- a/legacy/go/config/config.go +++ b/legacy/go/config/config.go @@ -168,7 +168,6 @@ func LoadVariables(filename string) (map[string]string, error) { if err != nil { return nil, fmt.Errorf("read %s: %w", filename, err) } - // Parse a flat TOML table of string values. vars := make(map[string]string) var table map[string]any if err := toml.Unmarshal(raw, &table); err != nil { @@ -180,8 +179,6 @@ func LoadVariables(filename string) (map[string]string, error) { return vars, nil } -// ── variable substitution ─────────────────────────────────────────────── - func resolveVars(in string, vars map[string]string) string { for k, v := range vars { in = strings.ReplaceAll(in, "$"+k, v) @@ -189,8 +186,6 @@ func resolveVars(in string, vars map[string]string) string { return in } -// ── embedded default config ───────────────────────────────────────────── - const defaultTOML = ` tty = 2 main_log_path = "/var/log/latchd.log" diff --git a/legacy/go/login/login.go b/legacy/go/login/login.go index 716169d..d83ad72 100644 --- a/legacy/go/login/login.go +++ b/legacy/go/login/login.go @@ -18,8 +18,6 @@ import ( "latchd/tui/bg" ) -// ── pixelated user avatar ─────────────────────────────────────────────── - var avatarArt = []string{ ` ▄▄▄▄▄ `, ` ▄███████▄ `, @@ -31,8 +29,6 @@ var avatarArt = []string{ ` ██ ██ `, } -// ── messages ───────────────────────────────────────────────────────────── - type tickMsg time.Time type authResult struct { @@ -57,8 +53,6 @@ const ( modeAdminPass ) -// ── result (sent back to main on successful login) ────────────────────── - type Result struct { Username string Password string @@ -67,8 +61,6 @@ type Result struct { Preview bool } -// ── model ─────────────────────────────────────────────────────────────── - type Model struct { cfg config.Config preview bool @@ -137,8 +129,6 @@ func (m *Model) Init() tea.Cmd { return tea.Batch(tick(), tea.EnterAltScreen) } -// ── update ────────────────────────────────────────────────────────────── - func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: @@ -222,8 +212,6 @@ func (m *Model) handleBypass(msg adminBypassResult) (tea.Model, tea.Cmd) { return m, nil } -// ── keys ──────────────────────────────────────────────────────────────── - func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { k := msg.String() @@ -392,8 +380,6 @@ func (m *Model) selectedEnv() session.PostLoginEnv { return m.envs[m.selIdx] } -// ── commands ──────────────────────────────────────────────────────────── - func (m *Model) loginCmd() tea.Cmd { if m.preview { return func() tea.Msg { time.Sleep(800 * time.Millisecond); return authResult{ok: true} } @@ -422,14 +408,10 @@ func (m *Model) adminBypassCmd() tea.Cmd { } } -// ── queries ──────────────────────────────────────────────────────────── - func (m *Model) Result() *Result { return m.result } func (m *Model) Quitting() bool { return m.quitting } func (m *Model) SetAutomata(a *bg.Automata) { m.automata = a } -// ── view ─────────────────────────────────────────────────────────────── - var ( accent = lipgloss.Color("208") // orange subtle = lipgloss.Color("8") // grey @@ -527,7 +509,6 @@ func overlayCenter(w, h int, content string) string { func (m *Model) renderLayout() string { clock := m.renderClock() - // top bar: power hints on left, clock on right var topParts []string topParts = append(topParts, m.renderPowerHints()) if clock != "" { @@ -535,24 +516,19 @@ func (m *Model) renderLayout() string { } top := strings.Join(topParts, " ") - // avatar + form side by side avatar := m.renderAvatar() form := m.renderForm() mid := lipgloss.JoinHorizontal(lipgloss.Top, avatar, " ", form) - // DE switcher with specifier label sw := m.renderSwitcher() - // caps lock caps := m.renderCapsLock() - // admin unlock var admin string if m.mode == modeAdminUser || m.mode == modeAdminPass { admin = m.renderAdminForm() } - // status st := m.renderStatus() var parts []string @@ -655,7 +631,6 @@ func (m *Model) renderSwitcher() string { var parts []string - // session specifier (like "x11", "wayland") spec := swDot.Render(m.envs[m.selIdx].Kind + " ") if cfg.ShowMovers && m.selIdx > 0 { @@ -679,7 +654,6 @@ func (m *Model) renderSwitcher() string { swLine := lipgloss.JoinHorizontal(lipgloss.Center, parts...) - // Dot indicators var dots []string for i := 0; i < len(m.envs); i++ { if i == m.selIdx { @@ -747,8 +721,6 @@ func (m *Model) renderStatus() string { return st.Align(lipgloss.Center).Width(70).Render(m.status) } -// ── caps lock detection ──────────────────────────────────────────────── - const kdgkbled = 0x4B64 func capsLockOn() bool { @@ -762,8 +734,6 @@ func capsLockOn() bool { return leds&0x04 != 0 } -// ── field editing ────────────────────────────────────────────────────── - func handleField(k string, buf *string, cur, scroll *int) { switch { case k == "backspace" || k == "ctrl+h": @@ -846,8 +816,6 @@ func abs(n int) int { return n } -// ── tick ──────────────────────────────────────────────────────────────── - func tick() tea.Cmd { return tea.Tick(time.Second/30, func(t time.Time) tea.Msg { return tickMsg(t) diff --git a/legacy/go/main.go b/legacy/go/main.go index 5b04fc8..7a2cb16 100644 --- a/legacy/go/main.go +++ b/legacy/go/main.go @@ -1,7 +1,3 @@ -// latchd — TUI display/login manager for GNU/Linux and BSD. -// -// Supports TTY, X11, and Wayland sessions with PAM authentication. -// Go rewrite of Lemurs (Rust source under vendor/). package main import ( @@ -193,8 +189,6 @@ func setupLog(path string) { log.SetFlags(log.LstdFlags) } -// ── chvt ──────────────────────────────────────────────────────────────── - const vtA = 0x5606 const vtW = 0x5607 const kbt = 0x4B33 diff --git a/legacy/go/session/env.go b/legacy/go/session/env.go index 3a085b5..f03211d 100644 --- a/legacy/go/session/env.go +++ b/legacy/go/session/env.go @@ -1,6 +1,3 @@ -// session — environment variable setup for latchd sessions. -// -// Mirrors vendor/src/post_login/env_variables.rs. package session import ( diff --git a/legacy/go/session/exec.go b/legacy/go/session/exec.go index 15c31e7..a4555cd 100644 --- a/legacy/go/session/exec.go +++ b/legacy/go/session/exec.go @@ -1,6 +1,3 @@ -// session — process execution and session spawning. -// -// Mirrors vendor/src/post_login/mod.rs and wait_with_log.rs. package session import ( diff --git a/legacy/go/session/scan.go b/legacy/go/session/scan.go index 4066dcb..342fb33 100644 --- a/legacy/go/session/scan.go +++ b/legacy/go/session/scan.go @@ -1,7 +1,3 @@ -// session — environment scanning from xsessions, wayland-sessions, -// and custom script directories. Includes desktop entry parser. -// -// Mirrors vendor/src/post_login/mod.rs:get_envs and parse_desktop_entry. package session import ( diff --git a/legacy/go/session/wayland.go b/legacy/go/session/wayland.go index 97c832c..ab87616 100644 --- a/legacy/go/session/wayland.go +++ b/legacy/go/session/wayland.go @@ -1,4 +1 @@ package session - -// Wayland session spawning is handled in exec.go/spawnWayland. -// This file exists for future Wayland-specific compositor logic. diff --git a/legacy/go/session/x11.go b/legacy/go/session/x11.go index 5090c13..e58021d 100644 --- a/legacy/go/session/x11.go +++ b/legacy/go/session/x11.go @@ -1,6 +1,3 @@ -// session — X11 server setup. -// -// Mirrors vendor/src/post_login/x.rs:setup_x. package session import ( @@ -47,7 +44,7 @@ func setupX(uid, gid uint32, groups []uint32, cfg *config.Config) (*exec.Cmd, er dd = "0" + dd } - // Ignore SIGUSR1 so Xorg sends it when ready. + // Xorg sends SIGUSR1 only if the parent ignores it during startup. signal.Ignore(syscall.SIGUSR1) cmd := exec.Command(cfg.SystemShell, "-c", diff --git a/legacy/go/tui/bg/automata.go b/legacy/go/tui/bg/automata.go index 4268046..ac51f58 100644 --- a/legacy/go/tui/bg/automata.go +++ b/legacy/go/tui/bg/automata.go @@ -1,10 +1,5 @@ -// bg — cellular automata background for the TUI. -// -// Conway's Game of Life rendered at 30 Hz using Unicode half-block -// characters (▀) to double the effective vertical resolution. -// The grid is double-buffered for zero-allocation ticking, and -// render output is built in a pre-allocated strings.Builder so the - +// The renderer packs two cell rows into each Unicode half-block row. +// Two buffers let Tick update the grid without an allocation per frame. package bg import ( @@ -12,7 +7,6 @@ import ( "strings" ) -// Bright orange (accent) for alive cells, dark grey for dead. const ( fgAlive = "\x1b[38;2;255;127;0m" fgDead = "\x1b[38;2;30;30;30m" @@ -92,8 +86,6 @@ func (a *Automata) count(g []uint8, x, y, w, h int) uint8 { return c } -// Frame returns the current generation as a single string suitable for -// writing to the alternate screen. func (a *Automata) Frame() string { a.buf.Reset() src := a.a diff --git a/scripts/test-cli.sh b/scripts/test-cli.sh index a57adab..67fcc60 100755 --- a/scripts/test-cli.sh +++ b/scripts/test-cli.sh @@ -1,9 +1,5 @@ #!/bin/sh -# Exercise the non-interactive CLI contract without authenticating a user. -# -# This suite intentionally uses --preview. It validates TOML parsing, command -# line validation, build metadata, and the preflight path without requiring a -# PAM secret, a controlling VT, or a running display server. +# Use --preview so this test does not need PAM, a VT, or a display server. set -eu binary=${1:-target/debug/latchd} diff --git a/scripts/test-pam-fixture.sh b/scripts/test-pam-fixture.sh new file mode 100755 index 0000000..e96cf45 --- /dev/null +++ b/scripts/test-pam-fixture.sh @@ -0,0 +1,93 @@ +#!/bin/sh +set -eu + +if [ "$(id -u)" -ne 0 ]; then + echo "pam-fixture: SKIP (root is required to install a temporary PAM policy)" + exit 0 +fi + +service="latchd-fixture-$$" +session_service="latchd-session-fixture-$$" +policy="/etc/pam.d/$service" +session_policy="/etc/pam.d/$session_service" +checker="/tmp/latchd-pam-session-check-$$" +module_source="/tmp/latchd-pam-session-env-$$.c" +module="/tmp/latchd-pam-session-env-$$.so" +cleanup() { + rm -f "$policy" "$session_policy" "$checker" "$module_source" "$module" +} +trap cleanup EXIT HUP INT TERM + +umask 077 +cat >"$policy" <<'PAM' +auth required pam_deny.so +account required pam_permit.so +password required pam_deny.so +session required pam_permit.so +PAM + +cat >"$checker" <<'SH' +#!/bin/sh +set -eu + +[ "${PAM_TTY:-}" = "tty2" ] +[ "${XDG_SESSION_CLASS:-}" = "user" ] +[ "${XDG_SESSION_TYPE:-}" = "wayland" ] +[ "${XDG_SESSION_DESKTOP:-}" = "niri" ] +[ "${XDG_SEAT:-}" = "seat0" ] +[ "${XDG_VTNR:-}" = "2" ] +SH +chmod 700 "$checker" + +cat >"$module_source" <<'C' +#include +#include + +PAM_EXTERN int pam_sm_open_session( + pam_handle_t *pamh, + int flags, + int argc, + const char **argv +) { + int result; + (void)flags; + (void)argc; + (void)argv; + + result = pam_putenv(pamh, "XDG_SESSION_ID=fixture-session"); + if (result != PAM_SUCCESS) return result; + result = pam_putenv(pamh, "XDG_RUNTIME_DIR=/tmp/latchd-runtime-fixture"); + if (result != PAM_SUCCESS) return result; + return pam_putenv( + pamh, + "DBUS_SESSION_BUS_ADDRESS=unix:path=/tmp/latchd-runtime-fixture/bus" + ); +} + +PAM_EXTERN int pam_sm_close_session( + pam_handle_t *pamh, + int flags, + int argc, + const char **argv +) { + (void)pamh; + (void)flags; + (void)argc; + (void)argv; + return PAM_SUCCESS; +} +C +${CC:-cc} -shared -fPIC "$module_source" -o "$module" -lpam +chmod 700 "$module" + +cat >"$session_policy" </dev/null | grep -q 'libpam\.so'; then pass "PAM runtime library is available" else @@ -70,16 +65,14 @@ else skip "faillock helper is unavailable" fi -# A real VT is a character device and must be allocated by the host/VM. A -# pseudo-terminal from CI is not equivalent and is intentionally not accepted. +# A pseudo-terminal does not test VT allocation by the host or VM. if [ -c /dev/tty0 ] && [ -d /sys/class/tty/tty0 ]; then pass "Linux virtual terminal device is available" else skip "Linux virtual terminal is unavailable (container or non-console host)" fi -# Xvfb is a genuine X server and is safe to exercise without host display -# access. If it is absent, this is a skip rather than a synthetic success. +# Xvfb tests X11 without access to the host display. if need_command Xvfb; then display_number=$((100 + ($$ % 500))) display=":$display_number" @@ -113,8 +106,7 @@ else skip "Xvfb is unavailable" fi -# A Wayland socket is meaningful only when a compositor owns it. Do not claim -# Wayland support merely because a client library or executable is installed. +# A compositor must own the Wayland socket. Installed client libraries are not sufficient. wayland_socket= if [ -n "${WAYLAND_DISPLAY:-}" ] && [ -n "${XDG_RUNTIME_DIR:-}" ]; then case "$WAYLAND_DISPLAY" in @@ -128,8 +120,7 @@ else skip "Wayland compositor socket is unavailable (requires a compositor-backed VM/session)" fi -# systemd-logind is not normally PID 1 in a Docker container. loginctl output -# is checked only when the manager and seat are actually reachable. +# Check loginctl only when systemd-logind is available. if need_command loginctl && [ -d /run/systemd/system ]; then if loginctl --no-pager show-seat seat0 >/dev/null 2>&1; then pass "systemd-logind seat0 is reachable"