feat: advance login workflow and integration gates

This commit is contained in:
2026-09-26 01:18:57 +05:30
parent 71de5bc6cd
commit 9492dbd7c9
16 changed files with 1471 additions and 151 deletions

View File

@@ -6,7 +6,7 @@ use std::{
path::{Path, PathBuf},
process::{Command, Stdio},
thread,
time::{Duration, Instant},
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
};
use crate::{
@@ -29,6 +29,87 @@ pub struct Identity {
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,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ShadowEntry {
hash: String,
last_change: Option<i64>,
max_days: Option<i64>,
inactive_days: Option<i64>,
account_expiry: Option<i64>,
}
/// A non-authoritative account lockout summary.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct FaillockStatus {
@@ -81,6 +162,96 @@ pub fn discover(config: &AccountsConfig) -> Vec<Identity> {
accounts
}
/// Discovers identities and attaches account-aging state when the shadow
/// database is readable. A missing or inaccessible shadow file yields
/// [`AccountState::Unknown`] rather than hiding the account.
pub fn discover_with_status(config: &AccountsConfig) -> Vec<AccountRecord> {
discover(config)
.into_iter()
.map(|identity| {
let status = inspect_account(&identity, config).unwrap_or(AccountStatus {
state: AccountState::Unknown,
password_changed_day: None,
password_expiry_day: None,
account_expiry_day: None,
inactive_after_day: None,
});
AccountRecord { identity, status }
})
.collect()
}
/// Inspects password and account aging fields without returning password
/// hashes. The configured shadow path must be root-owned and non-writable.
pub fn inspect_account(
identity: &Identity,
config: &AccountsConfig,
) -> Result<AccountStatus, Error> {
if !trusted_shadow(&config.shadow_path) {
return Err(Error::Config("shadow_path is not a trusted file".into()));
}
let content = std::fs::read_to_string(&config.shadow_path)
.io_context(format!("read {}", config.shadow_path.display()))?;
let entry = content.lines().find_map(|line| {
let (username, _) = line.split_once(':')?;
(username == identity.username)
.then(|| parse_shadow_entry(line))
.flatten()
});
let day = unix_day();
Ok(account_status_from_shadow(identity, entry, day))
}
/// Returns all active UTMPX user-process records in deterministic order.
pub fn active_sessions() -> Vec<ActiveSession> {
let mut sessions = Vec::new();
unsafe {
libc::setutxent();
loop {
let entry = libc::getutxent();
if entry.is_null() {
break;
}
let record = &*entry;
if record.ut_type != libc::USER_PROCESS {
continue;
}
let Some(username) = utmp_field(&record.ut_user) else {
continue;
};
let Some(terminal) = utmp_field(&record.ut_line) else {
continue;
};
sessions.push(ActiveSession {
username,
terminal,
pid: record.ut_pid,
host: utmp_field(&record.ut_host),
});
}
libc::endutxent();
}
sessions.sort_by(|left, right| {
left.username
.cmp(&right.username)
.then_with(|| left.terminal.cmp(&right.terminal))
.then_with(|| left.pid.cmp(&right.pid))
});
sessions
}
/// Detects whether systemd-logind has initialized its runtime seat state.
pub fn session_backend() -> SessionBackend {
if logind_markers_present(
Path::new("/run/systemd/system").is_dir(),
Path::new("/run/systemd/seats").is_dir(),
) {
SessionBackend::SystemdLogind
} else {
SessionBackend::Utmpx
}
}
/// Runs the configured helper and parses its stable numeric output.
pub fn faillock_status(username: &str, config: &AccountsConfig) -> Result<FaillockStatus, Error> {
let helper = &config.faillock_path;
@@ -116,16 +287,26 @@ pub fn faillock_status(username: &str, config: &AccountsConfig) -> Result<Faillo
if !output.status.success() {
return Ok(FaillockStatus::default());
}
let failures = parse_failure_count(&output.stdout);
let max_tries = read_deny(&config.faillock_config);
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.
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));
Ok(FaillockStatus {
FaillockStatus {
available: true,
failures,
max_tries,
remaining,
locked_out: remaining.is_some_and(|value| value == 0),
})
}
}
fn eligible(username: &str, uid: u32, home: &Path, shell: &Path, config: &AccountsConfig) -> bool {
@@ -165,11 +346,49 @@ fn c_path(pointer: *const libc::c_char) -> Option<PathBuf> {
}
fn parse_failure_count(bytes: &[u8]) -> u32 {
String::from_utf8_lossy(bytes)
.lines()
.filter_map(|line| line.split_whitespace().next())
.find_map(|value| value.parse::<u32>().ok())
.unwrap_or_default()
let mut rows = 0u32;
for line in String::from_utf8_lossy(bytes).lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
if let Some((_, count)) = line.split_once(':')
&& let Some(value) = count.split_whitespace().next()
&& let Ok(parsed) = value.parse::<u32>()
{
return parsed;
}
let mut fields = line.split_whitespace();
let Some(first) = fields.next() else {
continue;
};
if let Ok(parsed) = first.parse::<u32>() {
return parsed;
}
if is_date(first) && fields.next().is_some_and(is_time) {
rows = rows.saturating_add(1);
}
}
rows
}
fn is_date(value: &str) -> bool {
let mut fields = value.split('-');
fields
.next()
.is_some_and(|year| year.len() == 4 && year.chars().all(|c| c.is_ascii_digit()))
&& fields
.next()
.is_some_and(|month| month.len() == 2 && month.chars().all(|c| c.is_ascii_digit()))
&& fields
.next()
.is_some_and(|day| day.len() == 2 && day.chars().all(|c| c.is_ascii_digit()))
&& fields.next().is_none()
}
fn is_time(value: &str) -> bool {
let mut fields = value.split(':');
fields.all(|part| part.len() == 2 && part.chars().all(|c| c.is_ascii_digit()))
}
fn read_deny(path: &Path) -> Option<u32> {
@@ -185,6 +404,118 @@ fn read_deny(path: &Path) -> Option<u32> {
})
}
fn trusted_shadow(path: &Path) -> bool {
let Ok(metadata) = std::fs::symlink_metadata(path) else {
return false;
};
metadata.is_file() && metadata.uid() == 0 && metadata.mode() & 0o022 == 0
}
fn parse_shadow_entry(line: &str) -> Option<ShadowEntry> {
let mut fields = line.split(':');
let username = fields.next()?;
if username.is_empty() {
return None;
}
let hash = fields.next()?.to_owned();
let last_change = parse_shadow_day(fields.next()?);
let _minimum_days = parse_shadow_day(fields.next()?);
let max_days = parse_shadow_day(fields.next()?);
let _warning_days = parse_shadow_day(fields.next()?);
let inactive_days = parse_shadow_day(fields.next()?);
let account_expiry = parse_shadow_day(fields.next()?);
let _reserved = fields.next()?;
Some(ShadowEntry {
hash,
last_change,
max_days,
inactive_days,
account_expiry,
})
}
fn parse_shadow_day(value: &str) -> Option<i64> {
(!value.is_empty() && value != "-1")
.then(|| value.parse::<i64>().ok())
.flatten()
.filter(|day| *day >= 0)
}
fn shadow_hash_is_locked(hash: &str) -> bool {
hash.is_empty() || hash.starts_with('!') || hash.starts_with('*')
}
fn account_status_from_shadow(
identity: &Identity,
entry: Option<ShadowEntry>,
today: i64,
) -> AccountStatus {
let Some(entry) = entry else {
return AccountStatus {
state: AccountState::Unknown,
password_changed_day: None,
password_expiry_day: None,
account_expiry_day: None,
inactive_after_day: None,
};
};
let password_expiry_day = entry
.last_change
.zip(entry.max_days)
.and_then(|(changed, max)| changed.checked_add(max));
let inactive_after_day = password_expiry_day
.zip(entry.inactive_days)
.and_then(|(expiry, inactive)| expiry.checked_add(inactive));
let state = if !identity.shell.is_absolute()
|| identity.shell == Path::new("/bin/false")
|| identity.shell == Path::new("/usr/sbin/nologin")
|| identity.shell == Path::new("/sbin/nologin")
{
AccountState::NoLoginShell
} else if entry.account_expiry.is_some_and(|expiry| expiry <= today) {
AccountState::AccountExpired
} else if shadow_hash_is_locked(&entry.hash) {
AccountState::Locked
} else if entry.last_change == Some(0)
|| password_expiry_day.is_some_and(|expiry| expiry <= today)
{
AccountState::PasswordExpired
} else if inactive_after_day.is_some_and(|expiry| expiry <= today) {
AccountState::AccountInactive
} else {
AccountState::Active
};
AccountStatus {
state,
password_changed_day: entry.last_change,
password_expiry_day,
account_expiry_day: entry.account_expiry,
inactive_after_day,
}
}
fn unix_day() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| (duration.as_secs() / 86_400) as i64)
.unwrap_or_default()
}
fn utmp_field<const N: usize>(field: &[libc::c_char; N]) -> Option<String> {
let length = field.iter().position(|byte| *byte == 0).unwrap_or(N);
if length == 0 {
return None;
}
let bytes: Vec<u8> = field[..length].iter().map(|byte| *byte as u8).collect();
String::from_utf8(bytes)
.ok()
.filter(|value| !value.is_empty())
}
fn logind_markers_present(system_dir: bool, seats_dir: bool) -> bool {
system_dir && seats_dir
}
fn trusted_helper(path: &Path) -> bool {
let Ok(metadata) = std::fs::symlink_metadata(path) else {
return false;
@@ -199,6 +530,56 @@ fn trusted_helper(path: &Path) -> bool {
mod tests {
use super::*;
#[test]
fn parses_faillock_rows_with_username_and_timestamp_prefixes() {
assert_eq!(parse_failure_count(b"alice: 4\n"), 4);
assert_eq!(
parse_failure_count(b"2026-01-01 12:00:00 TTY 1\n2026-01-02 12:00:00 TTY 1\n"),
2
);
}
#[test]
fn parses_shadow_aging_and_lock_markers_without_exposing_hashes() {
let entry = parse_shadow_entry("alice:$6$redacted:0:0:30:7:14:20000:").expect("entry");
assert_eq!(entry.last_change, Some(0));
assert_eq!(entry.max_days, Some(30));
assert_eq!(entry.inactive_days, Some(14));
assert_eq!(entry.account_expiry, Some(20_000));
assert!(shadow_hash_is_locked("!$6$hash"));
assert!(!shadow_hash_is_locked("$6$hash"));
}
#[test]
fn account_state_prioritises_non_login_shell_and_expiry() {
let identity = Identity {
username: "alice".into(),
uid: 1000,
gid: 1000,
home: "/home/alice".into(),
shell: "/usr/sbin/nologin".into(),
};
let status = account_status_from_shadow(
&identity,
Some(ShadowEntry {
hash: "*".into(),
last_change: Some(1),
max_days: Some(1),
inactive_days: Some(1),
account_expiry: Some(1),
}),
20_000,
);
assert_eq!(status.state, AccountState::NoLoginShell);
}
#[test]
fn detects_systemd_logind_only_when_runtime_markers_exist() {
assert!(logind_markers_present(true, true));
assert!(!logind_markers_present(true, false));
assert!(!logind_markers_present(false, true));
}
#[test]
fn parses_legacy_failure_count() {
assert_eq!(parse_failure_count(b" 4 2026-01-01 source\n"), 4);

View File

@@ -72,6 +72,8 @@ pub struct AccountsConfig {
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,
}
impl Default for AccountsConfig {
@@ -82,6 +84,7 @@ impl Default for AccountsConfig {
enumerate: true,
faillock_path: PathBuf::from("/usr/sbin/faillock"),
faillock_config: PathBuf::from("/etc/security/faillock.conf"),
shadow_path: PathBuf::from("/etc/shadow"),
}
}
}
@@ -293,6 +296,7 @@ pub fn validate(config: &Config) -> Result<(), Error> {
("x11.server_log_path", &config.x11.server_log_path),
("accounts.faillock_path", &config.accounts.faillock_path),
("accounts.faillock_config", &config.accounts.faillock_config),
("accounts.shadow_path", &config.accounts.shadow_path),
] {
if !path.is_absolute() {
return Err(Error::Config(format!("{name} must be an absolute path")));

View File

@@ -19,6 +19,8 @@ use ratatui::{
const MAX_IMAGE_SIDE: u32 = 480;
const MAX_SOURCE_SIDE: u32 = 8_192;
const MAX_SOURCE_PIXELS: u64 = 16_777_216;
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.
@@ -189,6 +191,10 @@ 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.
let width = width.min(MAX_ANIMATION_WIDTH);
let height = height.min(MAX_ANIMATION_HEIGHT);
if self.width == width && self.height == height {
return;
}
@@ -221,11 +227,11 @@ impl Automata {
let mut count = 0;
for dy in [self.height.wrapping_sub(1), 0, 1] {
for dx in [self.width.wrapping_sub(1), 0, 1] {
if dx == 0 && dy == 0 {
continue;
}
let nx = (x + dx) % self.width;
let ny = (y + dy) % self.height;
if nx == x && ny == y {
continue;
}
count += self.current[ny * self.width + nx];
}
}
@@ -264,4 +270,16 @@ mod tests {
automata.tick();
assert_eq!(automata.current.len(), 32);
}
#[test]
fn automata_caps_untrusted_terminal_dimensions() {
let mut automata = Automata::default();
automata.resize(usize::MAX, usize::MAX);
assert_eq!(automata.width, MAX_ANIMATION_WIDTH);
assert_eq!(automata.height, MAX_ANIMATION_HEIGHT);
assert_eq!(
automata.current.len(),
MAX_ANIMATION_WIDTH * MAX_ANIMATION_HEIGHT
);
}
}

View File

@@ -17,7 +17,7 @@ use zeroize::Zeroizing;
use latchd_auth::{AuthService, AuthTicket};
use latchd_core::{
accounts::{self, FaillockStatus, Identity},
accounts::{self, AccountState, AccountStatus, FaillockStatus, Identity},
config::Config,
error::{Error, ResultExt},
session::{self, SessionDescriptor},
@@ -30,6 +30,16 @@ const MIN_WIDTH: u16 = 60;
const MIN_HEIGHT: u16 = 16;
const MAX_INPUT: usize = 256;
fn unknown_account_status() -> AccountStatus {
AccountStatus {
state: AccountState::Unknown,
password_changed_day: None,
password_expiry_day: None,
account_expiry_day: None,
inactive_after_day: None,
}
}
/// The result of one completed login-screen interaction.
pub struct LoginSelection {
/// Authenticated PAM ticket.
@@ -45,6 +55,20 @@ enum Focus {
Password,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum FilterTarget {
Sessions,
Accounts,
}
#[derive(Debug, Clone)]
struct FilterState {
target: FilterTarget,
query: String,
}
type DiscoverySnapshot = (Vec<SessionDescriptor>, Vec<Identity>, Vec<AccountStatus>);
#[derive(Debug)]
enum AuthState {
Idle,
@@ -71,6 +95,7 @@ struct App {
preview: bool,
sessions: Vec<SessionDescriptor>,
accounts: Vec<Identity>,
account_statuses: Vec<AccountStatus>,
account_index: Option<usize>,
faillock_receiver: Option<Receiver<Result<FaillockStatus, Error>>>,
faillock_status: Option<FaillockStatus>,
@@ -80,6 +105,8 @@ struct App {
password: Zeroizing<String>,
status: String,
auth_state: AuthState,
auth_failures: u32,
auth_blocked_until: Option<Instant>,
show_help: bool,
power_confirmation: Option<usize>,
should_quit: bool,
@@ -89,6 +116,10 @@ struct App {
notice: Option<Notice>,
animation_frame: usize,
reduced_motion: bool,
filter: Option<FilterState>,
refresh_receiver: Option<Receiver<DiscoverySnapshot>>,
refreshing: bool,
high_contrast: bool,
}
impl App {
@@ -111,6 +142,7 @@ impl App {
auth,
preview,
sessions,
account_statuses: vec![unknown_account_status(); accounts.len()],
accounts,
account_index,
faillock_receiver: None,
@@ -121,6 +153,8 @@ impl App {
password: Zeroizing::new(String::new()),
status: "Enter your username".into(),
auth_state: AuthState::Idle,
auth_failures: 0,
auth_blocked_until: None,
show_help: false,
power_confirmation: None,
should_quit: false,
@@ -130,12 +164,17 @@ impl App {
notice: None,
animation_frame: 0,
reduced_motion,
filter: None,
refresh_receiver: None,
refreshing: false,
high_contrast: std::env::var_os("LATCHD_HIGH_CONTRAST").is_some(),
}
}
fn run(&mut self, terminal: &mut DefaultTerminal) -> Result<Option<LoginSelection>, Error> {
loop {
self.poll_faillock();
self.poll_refresh();
let now = Instant::now();
if self.background.tick_if_due(now) {
self.animation_frame = self.animation_frame.wrapping_add(1);
@@ -153,6 +192,8 @@ impl App {
if let Some(result) = self.poll_auth() {
match result {
Ok(ticket) => {
self.auth_failures = 0;
self.auth_blocked_until = None;
let selected = self
.sessions
.get(self.selected)
@@ -167,6 +208,9 @@ impl App {
tracing::warn!(%error, "authentication attempt failed");
self.auth_state = AuthState::Failed;
self.password.clear();
self.auth_failures = self.auth_failures.saturating_add(1);
self.auth_blocked_until =
Some(Instant::now() + auth_backoff(self.auth_failures));
self.status = "Authentication failed".into();
self.notify("Authentication failed", NoticeLevel::Warning);
self.start_faillock_query();
@@ -230,6 +274,98 @@ impl App {
}
}
fn poll_refresh(&mut self) {
let Some(receiver) = &self.refresh_receiver else {
return;
};
match receiver.try_recv() {
Ok((sessions, accounts, account_statuses)) => {
self.sessions = sessions;
self.accounts = accounts;
self.account_statuses = account_statuses;
self.refresh_receiver = None;
self.refreshing = false;
self.reconcile_selection();
self.status = format!(
"Discovery refreshed: {} session(s), {} account(s)",
self.sessions.len(),
self.accounts.len()
);
self.notify("Account and session discovery refreshed", NoticeLevel::Info);
}
Err(mpsc::TryRecvError::Empty) => {}
Err(mpsc::TryRecvError::Disconnected) => {
self.refresh_receiver = None;
self.refreshing = false;
self.status = "Discovery refresh failed".into();
self.notify(
"Could not refresh account and session discovery",
NoticeLevel::Warning,
);
}
}
}
fn start_refresh(&mut self) {
if self.refreshing {
self.status = "Discovery refresh is already running".into();
return;
}
let config = self.config.clone();
let (sender, receiver) = mpsc::sync_channel(1);
let task = thread::Builder::new()
.name("latchd-discovery".into())
.spawn(move || {
let sessions = session::discover(&config);
let (accounts, account_statuses) = if config.accounts.enumerate {
let records = accounts::discover_with_status(&config.accounts);
records
.into_iter()
.map(|record| (record.identity, record.status))
.unzip()
} else {
(Vec::new(), Vec::new())
};
let _ = sender.send((sessions, accounts, account_statuses));
});
match task {
Ok(_) => {
self.refresh_receiver = Some(receiver);
self.refreshing = true;
self.status = "Refreshing accounts and sessions…".into();
}
Err(error) => {
tracing::debug!(%error, "could not start discovery refresh");
self.status = format!("Could not start discovery refresh: {error}");
}
}
}
fn reconcile_selection(&mut self) {
if self.sessions.is_empty() {
self.selected = 0;
} else if self.selected >= self.sessions.len() {
self.selected = self.sessions.len() - 1;
}
if self.accounts.is_empty() {
self.account_index = None;
self.account_statuses.clear();
} else if self.account_statuses.len() != self.accounts.len() {
self.account_statuses
.resize(self.accounts.len(), unknown_account_status());
} else if self
.account_index
.is_some_and(|index| index >= self.accounts.len())
{
self.account_index = Some(0);
}
if let Some(index) = self.account_index {
if let Some(account) = self.accounts.get(index) {
self.username = account.username.clone();
}
}
}
fn start_faillock_query(&mut self) {
let username = self.username.clone();
if username.is_empty() {
@@ -262,6 +398,149 @@ impl App {
}
}
fn begin_filter(&mut self) {
let target = match self.focus {
Focus::Sessions => FilterTarget::Sessions,
Focus::Username => FilterTarget::Accounts,
Focus::Password => return,
};
self.filter = Some(FilterState {
target,
query: String::new(),
});
self.status = match target {
FilterTarget::Sessions => {
"Filter sessions: type text, Enter selects, Esc clears".into()
}
FilterTarget::Accounts => {
"Filter accounts: type text, Enter selects, Esc clears".into()
}
};
}
fn filter_key(&mut self, key: KeyEvent) {
let Some(target) = self.filter.as_ref().map(|filter| filter.target) else {
return;
};
match key.code {
KeyCode::Esc => {
self.filter = None;
self.status = "Filter cleared".into();
}
KeyCode::Enter => self.accept_filter(),
KeyCode::Backspace => {
if let Some(filter) = &mut self.filter {
filter.query.pop();
}
self.update_filter_status();
}
KeyCode::Up | KeyCode::Left => match target {
FilterTarget::Sessions => self.previous_session(),
FilterTarget::Accounts => self.previous_account(),
},
KeyCode::Down | KeyCode::Right => match target {
FilterTarget::Sessions => self.next_session(),
FilterTarget::Accounts => self.next_account(),
},
KeyCode::Char(character)
if !key
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
&& self
.filter
.as_ref()
.is_some_and(|filter| filter.query.chars().count() < MAX_INPUT) =>
{
if let Some(filter) = &mut self.filter {
filter.query.push(character);
}
self.update_filter_status();
}
_ => {}
}
}
fn update_filter_status(&mut self) {
let Some(filter) = &self.filter else {
return;
};
let count = match filter.target {
FilterTarget::Sessions => self.matching_session_indices().len(),
FilterTarget::Accounts => self.matching_account_indices().len(),
};
self.status = format!(
"Filter {}: {} match(es) — Enter selects, Esc clears",
match filter.target {
FilterTarget::Sessions => "sessions",
FilterTarget::Accounts => "accounts",
},
count
);
}
fn accept_filter(&mut self) {
let Some(filter) = self.filter.take() else {
return;
};
match filter.target {
FilterTarget::Sessions => {
let index = self
.sessions
.iter()
.enumerate()
.find(|(_, session)| matches_filter(&session.title, &filter.query))
.map(|(index, _)| index);
if let Some(index) = index {
self.selected = index;
self.status = format!("Selected session {}", self.sessions[index].title);
} else {
self.status = "No matching sessions".into();
}
}
FilterTarget::Accounts => {
let index = self
.accounts
.iter()
.enumerate()
.find(|(_, account)| matches_filter(&account.username, &filter.query))
.map(|(index, _)| index);
if let Some(index) = index {
self.select_account_index(index);
} else {
self.status = "No matching accounts".into();
}
}
}
}
fn matching_session_indices(&self) -> Vec<usize> {
let query = self
.filter
.as_ref()
.filter(|filter| filter.target == FilterTarget::Sessions)
.map_or("", |filter| filter.query.as_str());
self.sessions
.iter()
.enumerate()
.filter(|(_, session)| matches_filter(&session.title, query))
.map(|(index, _)| index)
.collect()
}
fn matching_account_indices(&self) -> Vec<usize> {
let query = self
.filter
.as_ref()
.filter(|filter| filter.target == FilterTarget::Accounts)
.map_or("", |filter| filter.query.as_str());
self.accounts
.iter()
.enumerate()
.filter(|(_, account)| matches_filter(&account.username, query))
.map(|(index, _)| index)
.collect()
}
fn handle_key(&mut self, key: KeyEvent) {
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
self.should_quit = true;
@@ -279,6 +558,10 @@ impl App {
}
return;
}
if self.filter.is_some() {
self.filter_key(key);
return;
}
if self.power_confirmation.is_none() && key.modifiers.contains(KeyModifiers::CONTROL) {
match key.code {
KeyCode::Char('n') => {
@@ -314,6 +597,10 @@ impl App {
);
return;
}
KeyCode::Char('r') => {
self.start_refresh();
return;
}
_ => {}
}
}
@@ -328,6 +615,7 @@ impl App {
}
match key.code {
KeyCode::Char('?') => self.show_help = true,
KeyCode::Char('/') => self.begin_filter(),
KeyCode::Esc => {
self.password.clear();
self.status = "Login cancelled".into();
@@ -409,6 +697,16 @@ impl App {
self.status = "Username and password are required".into();
return;
}
if let Some(until) = self.auth_blocked_until {
if let Some(remaining) = until.checked_duration_since(Instant::now()) {
self.status = format!(
"Authentication paused after failed attempts; try again in {}s",
remaining.as_secs().saturating_add(1)
);
return;
}
self.auth_blocked_until = None;
}
let username = self.username.clone();
let password = self.password.as_bytes().to_vec();
let service = self.config.pam_service.clone();
@@ -483,17 +781,23 @@ impl App {
}
fn next_session(&mut self) {
if !self.sessions.is_empty() {
self.selected = (self.selected + 1) % self.sessions.len();
let indices = self.matching_session_indices();
if let Some(position) = indices.iter().position(|&index| index == self.selected) {
self.selected = indices[(position + 1) % indices.len()];
} else if let Some(&index) = indices.first() {
self.selected = index;
}
}
fn previous_session(&mut self) {
if !self.sessions.is_empty() {
self.selected = self
.selected
.checked_sub(1)
.unwrap_or(self.sessions.len() - 1);
let indices = self.matching_session_indices();
if let Some(position) = indices.iter().position(|&index| index == self.selected) {
self.selected = indices
.get(position.checked_sub(1).unwrap_or(indices.len() - 1))
.copied()
.unwrap_or(self.selected);
} else if let Some(&index) = indices.first() {
self.selected = index;
}
}
@@ -506,20 +810,36 @@ impl App {
}
fn select_account(&mut self, delta: isize) {
if self.accounts.is_empty() {
let indices = self.matching_account_indices();
if indices.is_empty() {
self.notify(
"No eligible accounts discovered; enter a username manually",
NoticeLevel::Info,
);
return;
}
let current = self.account_index.unwrap_or(0) as isize;
let length = self.accounts.len() as isize;
let index = (current + delta).rem_euclid(length) as usize;
let current = self
.account_index
.and_then(|index| indices.iter().position(|&candidate| candidate == index))
.unwrap_or(0) as isize;
let index = indices[(current + delta).rem_euclid(indices.len() as isize) as usize];
self.select_account_index(index);
}
fn select_account_index(&mut self, index: usize) {
let Some(username) = self
.accounts
.get(index)
.map(|account| account.username.clone())
else {
return;
};
self.account_index = Some(index);
self.username = self.accounts[index].username.clone();
self.username = username;
self.password.clear();
self.auth_state = AuthState::Idle;
self.auth_failures = 0;
self.auth_blocked_until = None;
self.faillock_status = None;
self.status = format!("Selected account {}", self.username);
self.start_faillock_query();
@@ -576,10 +896,10 @@ impl App {
} else {
vertical[1]
};
let session_items: Vec<ListItem> = self
.sessions
let session_indices = self.matching_session_indices();
let session_items: Vec<ListItem> = session_indices
.iter()
.enumerate()
.filter_map(|&index| self.sessions.get(index).map(|session| (index, session)))
.map(|(index, session)| {
let marker = if index == self.selected {
if self.ascii { ">" } else { "▶" }
@@ -600,8 +920,22 @@ impl App {
})
})
.collect();
let sessions =
List::new(session_items).block(self.block("Session", self.focus == Focus::Sessions));
let session_items = if session_items.is_empty() {
vec![ListItem::new(" No matching sessions").style(self.style_muted())]
} else {
session_items
};
let session_title = if self
.filter
.as_ref()
.is_some_and(|filter| filter.target == FilterTarget::Sessions)
{
format!("Session {}/{}", session_indices.len(), self.sessions.len())
} else {
"Session".into()
};
let sessions = List::new(session_items)
.block(self.block(&session_title, self.focus == Focus::Sessions));
frame.render_widget(sessions, session_area);
let fields = Layout::default()
@@ -622,26 +956,97 @@ impl App {
frame.render_widget(password, fields[1]);
let status = self.status_line();
frame.render_widget(Paragraph::new(status).wrap(Wrap { trim: true }), fields[2]);
frame.render_widget(
Paragraph::new(
"Tab focus ←/→ session Ctrl-N/P account Ctrl-B effects Ctrl-M motion ? help",
),
vertical[2],
);
frame.render_widget(Paragraph::new(self.footer_hint()), vertical[2]);
frame.render_widget(
Paragraph::new("PAM authentication • session output stays off the login screen"),
vertical[3],
);
if self.show_help {
self.draw_help(frame, area);
} else if self.filter.is_some() {
self.draw_filter(frame, area);
}
}
fn footer_hint(&self) -> String {
let mut hint = match self.focus {
Focus::Sessions => "Tab focus ←/→ choose / filter Ctrl-R refresh ? help",
Focus::Username => "Tab focus / filter Ctrl-N/P switch Ctrl-R refresh ? help",
Focus::Password => "Tab focus Enter login Ctrl-M motion ? help",
}
.to_owned();
if self.no_color {
hint.push_str(" [monochrome]");
} else if self.high_contrast {
hint.push_str(" [high contrast]");
}
if self.reduced_motion {
hint.push_str(" [reduced motion]");
}
hint
}
fn draw_filter(&self, frame: &mut ratatui::Frame, area: Rect) {
let Some(filter) = &self.filter else {
return;
};
let popup = centered_rect(72, 52, area);
frame.render_widget(Clear, popup);
let (label, values): (&str, Vec<String>) = match filter.target {
FilterTarget::Sessions => (
"Sessions",
self.matching_session_indices()
.iter()
.filter_map(|&index| self.sessions.get(index))
.map(|session| session.title.clone())
.collect(),
),
FilterTarget::Accounts => (
"Accounts",
self.matching_account_indices()
.iter()
.filter_map(|&index| self.accounts.get(index))
.map(|account| account.username.clone())
.collect(),
),
};
let mut lines = vec![
Line::from(format!("{label} /{}", filter.query)),
Line::raw(""),
];
if values.is_empty() {
lines.push(Line::from("No matches"));
} else {
for value in values.iter().take(8) {
lines.push(Line::from(format!(
" {}",
truncate_cells(value, popup.width.saturating_sub(5) as usize)
)));
}
}
lines.push(Line::raw(""));
lines.push(Line::from(
"Type to filter • ↑/↓ choose • Enter accept • Esc clear",
));
frame.render_widget(
Paragraph::new(lines)
.block(self.block("Filter", true))
.wrap(Wrap { trim: true }),
popup,
);
}
fn draw_help(&self, frame: &mut ratatui::Frame, area: Rect) {
let popup = centered_rect(70, 50, area);
frame.render_widget(Clear, popup);
frame.render_widget(Paragraph::new("Keyboard help\n\nTab / Shift-Tab move focus\nArrow keys choose session\nCtrl-N / Ctrl-P switch account\nCtrl-B toggle background\nCtrl-M toggle reduced motion\nEnter authenticate\nEsc clear password\nCtrl-C quit cleanly\n? close help")
.block(self.block("Help", false)).wrap(Wrap { trim: true }), popup);
frame.render_widget(
Paragraph::new(
"Keyboard help\n\nTab / Shift-Tab move focus\nArrow keys choose session\n/ filter sessions or accounts\nCtrl-N / Ctrl-P switch account\nCtrl-R refresh discovery\nCtrl-B toggle background\nCtrl-M toggle reduced motion\nEnter authenticate / accept filter\nEsc clear password / close popup\nCtrl-C quit cleanly\n? close help",
)
.block(self.block("Help", false))
.wrap(Wrap { trim: true }),
popup,
);
}
fn block(&self, title: &str, focused: bool) -> Block<'static> {
@@ -680,6 +1085,14 @@ impl App {
};
status = format!("{spinner} Authenticating…");
}
if let Some(until) = self.auth_blocked_until {
if let Some(remaining) = until.checked_duration_since(Instant::now()) {
status.push_str(&format!(
" • retry cooldown {}s",
remaining.as_secs().saturating_add(1)
));
}
}
if let Some(lockout) = self.faillock_status {
if lockout.locked_out {
status
@@ -694,6 +1107,21 @@ impl App {
));
}
}
if let Some(index) = self.account_index {
if let Some(account) = self.account_statuses.get(index) {
let policy = match account.state {
AccountState::Active | AccountState::Unknown => None,
AccountState::Locked => Some("account password is locked"),
AccountState::PasswordExpired => Some("password must be changed"),
AccountState::AccountExpired => Some("account has expired"),
AccountState::AccountInactive => Some("account is inactive"),
AccountState::NoLoginShell => Some("account has no login shell"),
};
if let Some(policy) = policy {
status.push_str(&format!(" • {policy}; PAM remains authoritative"));
}
}
}
if let Some(notice) = &self.notice {
let marker = match notice.level {
NoticeLevel::Info => {
@@ -713,12 +1141,20 @@ impl App {
};
status.push_str(&format!(" {marker} {}", notice.message));
}
if self.refreshing {
status.push_str(" • refreshing discovery");
}
status
}
fn style_accent(&self) -> Style {
if self.no_color {
Style::default().add_modifier(ratatui::prelude::Modifier::BOLD)
} else if self.high_contrast {
Style::default()
.fg(ratatui::prelude::Color::White)
.bg(ratatui::prelude::Color::Blue)
.add_modifier(ratatui::prelude::Modifier::BOLD)
} else {
Style::default()
.fg(ratatui::prelude::Color::Cyan)
@@ -728,6 +1164,11 @@ impl App {
fn style_selected(&self) -> Style {
if self.no_color {
Style::default().add_modifier(ratatui::prelude::Modifier::REVERSED)
} else if self.high_contrast {
Style::default()
.fg(ratatui::prelude::Color::Black)
.bg(ratatui::prelude::Color::White)
.add_modifier(ratatui::prelude::Modifier::BOLD)
} else {
Style::default()
.fg(ratatui::prelude::Color::Yellow)
@@ -746,13 +1187,18 @@ pub fn run(
preview: bool,
) -> Result<Option<LoginSelection>, Error> {
let sessions = session::discover(&config);
let accounts = if config.accounts.enumerate {
accounts::discover(&config.accounts)
let (accounts, account_statuses) = if config.accounts.enumerate {
let records = accounts::discover_with_status(&config.accounts);
records
.into_iter()
.map(|record| (record.identity, record.status))
.unzip()
} else {
Vec::new()
(Vec::new(), Vec::new())
};
let background = Background::load(&config.visual)?;
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.start_faillock_query();
ratatui::run(|terminal| app.run(terminal)).map_err(|error| Error::Terminal(error.to_string()))
@@ -795,6 +1241,22 @@ fn truncate_cells(value: &str, width: usize) -> String {
output
}
fn matches_filter(value: &str, query: &str) -> bool {
if query.is_empty() {
return true;
}
if query.chars().any(char::is_uppercase) {
value.contains(query)
} else {
value.to_lowercase().contains(query)
}
}
fn auth_backoff(failures: u32) -> Duration {
let shift = failures.saturating_sub(1).min(4);
Duration::from_secs(1u64 << shift)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -839,6 +1301,40 @@ mod tests {
assert!(rendered.contains("Password"));
}
#[test]
fn renders_without_layout_errors_at_supported_breakpoints() {
for (width, height) in [(80, 24), (79, 23), (60, 24)] {
let auth = AuthService::new().expect("PAM worker should start");
let mut app = App::new(
Config::default(),
auth,
true,
vec![SessionDescriptor {
kind: session::SessionKind::Tty,
title: "TTY shell".into(),
argv: Vec::new(),
current_desktop: None,
session_desktop: None,
}],
Vec::new(),
Background::load(&Config::default().visual)
.expect("default background should load"),
);
let backend = TestBackend::new(width, height);
let mut terminal = Terminal::new(backend).expect("test terminal should initialize");
terminal
.draw(|frame| app.draw(frame))
.expect("frame should render");
let rendered = terminal.backend().buffer().content();
assert!(rendered.iter().any(|cell| cell.symbol() == "l"));
assert!(
!rendered
.iter()
.any(|cell| cell.symbol() == "terminal too small")
);
}
}
#[test]
fn renders_a_truthful_too_small_state() {
let auth = AuthService::new().expect("PAM worker should start");
@@ -898,4 +1394,106 @@ mod tests {
assert!(app.password.is_empty());
assert!(matches!(app.auth_state, AuthState::Idle));
}
#[test]
fn account_policy_state_is_visible_without_overriding_pam() {
let auth = AuthService::new().expect("PAM worker should start");
let account = Identity {
username: "alice".into(),
uid: 1000,
gid: 1000,
home: "/home/alice".into(),
shell: "/bin/sh".into(),
};
let mut app = App::new(
Config::default(),
auth,
true,
Vec::new(),
vec![account],
Background::load(&Config::default().visual).expect("default background should load"),
);
app.account_statuses[0].state = AccountState::Locked;
let status = app.status_line();
assert!(status.contains("password is locked"));
assert!(status.contains("PAM remains authoritative"));
}
#[test]
fn authentication_backoff_is_exponential_and_bounded() {
assert_eq!(auth_backoff(1), Duration::from_secs(1));
assert_eq!(auth_backoff(2), Duration::from_secs(2));
assert_eq!(auth_backoff(5), Duration::from_secs(16));
assert_eq!(auth_backoff(100), Duration::from_secs(16));
}
#[test]
fn filtering_is_smart_case_and_selects_a_matching_session() {
let auth = AuthService::new().expect("PAM worker should start");
let mut app = App::new(
Config::default(),
auth,
true,
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 session".into(),
argv: vec!["sway".into()],
current_desktop: None,
session_desktop: None,
},
],
Vec::new(),
Background::load(&Config::default().visual).expect("default background should load"),
);
app.focus = Focus::Sessions;
app.handle_key(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE));
app.handle_key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE));
app.handle_key(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE));
assert_eq!(app.matching_session_indices(), vec![1]);
app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(app.selected, 1);
assert!(app.filter.is_none());
}
#[test]
fn refresh_reconciles_selection_without_blocking_state() {
let auth = AuthService::new().expect("PAM worker should start");
let mut app = App::new(
Config::default(),
auth,
true,
Vec::new(),
Vec::new(),
Background::load(&Config::default().visual).expect("default background should load"),
);
app.refresh_receiver = Some({
let (sender, receiver) = mpsc::sync_channel(1);
sender
.send((
vec![SessionDescriptor {
kind: session::SessionKind::Tty,
title: "TTY shell".into(),
argv: Vec::new(),
current_desktop: None,
session_desktop: None,
}],
Vec::new(),
Vec::new(),
))
.expect("refresh result should queue");
receiver
});
app.refreshing = true;
app.poll_refresh();
assert!(!app.refreshing);
assert_eq!(app.sessions.len(), 1);
}
}