refactor: split the TUI and refresh project docs

Move login state, authentication, discovery, input, rendering, and tests into focused TUI modules. Add detailed CLI error reports with recovery commands, align the build and host-check documentation, and keep the legacy Go tree outside normal builds.

Checks: make check; make integration-test; go test ./... (legacy/go).
This commit is contained in:
2026-10-05 21:37:15 +05:30
parent de8843471b
commit f2a885d621
33 changed files with 2866 additions and 2173 deletions

View File

@@ -1,4 +1,4 @@
//! Linux UTMPX accounting with idempotent RAII cleanup.
//! Write and close UTMPX records for user sessions.
use std::{io, mem, os::raw::c_char, ptr};
@@ -10,7 +10,7 @@ pub struct UtmpxGuard {
}
impl UtmpxGuard {
/// Adds a user-process record for a child and returns its cleanup guard.
/// Add a user-process record and return the cleanup guard.
pub fn start(username: &str, tty: u8, pid: libc::pid_t) -> Result<Self, Error> {
let mut record: libc::utmpx = unsafe { mem::zeroed() };
record.ut_type = libc::USER_PROCESS;
@@ -26,7 +26,6 @@ impl UtmpxGuard {
})
}
/// Writes the corresponding dead-process record. Calling it twice is safe.
pub fn close(&mut self) -> Result<(), Error> {
if self.closed {
return Ok(());

View File

@@ -1,4 +1,4 @@
//! NSS account discovery and advisory faillock status.
//! Discover login accounts and display account policy state.
use std::{
ffi::CStr,
@@ -23,7 +23,7 @@ pub struct Identity {
pub shell: PathBuf,
}
/// Effective account state determined from the login shell and shadow aging data.
/// Account state derived from the shell and shadow aging fields.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AccountState {
Active,
@@ -32,7 +32,7 @@ pub enum AccountState {
AccountExpired,
AccountInactive,
NoLoginShell,
/// Shadow data was not available or could not be parsed.
/// Shadow data was missing or could not be parsed.
Unknown,
}
@@ -65,7 +65,7 @@ pub struct ActiveSession {
pub host: Option<String>,
}
/// Runtime session-management backend detected on this host.
/// Session backend found on the host.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SessionBackend {
SystemdLogind,
@@ -81,7 +81,7 @@ struct ShadowEntry {
account_expiry: Option<i64>,
}
/// A non-authoritative account lockout summary.
/// Parsed faillock counts for the selected account.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct FaillockStatus {
pub available: bool,
@@ -91,7 +91,7 @@ pub struct FaillockStatus {
pub locked_out: bool,
}
/// Enumerates interactive accounts using NSS and deterministic ordering.
/// Read interactive accounts through NSS and sort them by username.
pub fn discover(config: &AccountsConfig) -> Vec<Identity> {
let mut accounts = Vec::new();
unsafe {
@@ -128,9 +128,8 @@ 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.
/// Read identities and attach shadow aging state when the file is available.
/// Missing shadow data produces [`AccountState::Unknown`] for that account.
pub fn discover_with_status(config: &AccountsConfig) -> Vec<AccountRecord> {
discover(config)
.into_iter()
@@ -147,8 +146,8 @@ pub fn discover_with_status(config: &AccountsConfig) -> Vec<AccountRecord> {
.collect()
}
/// Inspects password and account aging fields without returning password
/// hashes. The configured shadow path must be root-owned and non-writable.
/// Read aging fields without returning password hashes; the shadow path must
/// be root-owned and not writable by group or other users.
pub fn inspect_account(
identity: &Identity,
config: &AccountsConfig,
@@ -168,7 +167,7 @@ pub fn inspect_account(
Ok(account_status_from_shadow(identity, entry, day))
}
/// Returns all active UTMPX user-process records in deterministic order.
/// Read active UTMPX user-process records in sorted order.
pub fn active_sessions() -> Vec<ActiveSession> {
let mut sessions = Vec::new();
unsafe {
@@ -206,7 +205,7 @@ pub fn active_sessions() -> Vec<ActiveSession> {
sessions
}
/// Detects whether systemd-logind has initialized its runtime seat state.
/// Check 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(),
@@ -218,7 +217,7 @@ pub fn session_backend() -> SessionBackend {
}
}
/// Runs the configured helper and parses its stable numeric output.
/// Run the configured helper and parse its numeric output.
pub fn faillock_status(username: &str, config: &AccountsConfig) -> Result<FaillockStatus, Error> {
let helper = &config.faillock_path;
if !helper.is_absolute() {
@@ -257,8 +256,7 @@ pub fn faillock_status(username: &str, config: &AccountsConfig) -> Result<Faillo
Ok(faillock_status_from_output(&output.stdout, max_tries))
}
/// Parses the legacy count format and current rows with usernames or timestamps.
/// Malformed output reports zero failures and does not control authentication.
/// Parse legacy counts and timestamp rows; bad output produces zero failures.
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));

View File

@@ -1,12 +1,10 @@
//! Redacted security audit events.
//!
//! Audit records exclude secrets, PAM messages, and command arguments.
//! Logs audit events for login operations structurally, without including free-form messages from PAM modules that may contain sensitive information.
use std::fmt;
use crate::error::Error;
/// The security operation that produced an audit event.
/// Login operation that produced an audit event.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AuditAction {
Authentication,
@@ -24,7 +22,7 @@ impl fmt::Display for AuditAction {
}
}
/// The outcome of a security operation.
/// Result recorded for a login operation.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AuditOutcome {
Started,
@@ -44,7 +42,7 @@ impl fmt::Display for AuditOutcome {
}
}
/// A safe category for a PAM failure, without exposing PAM's free-form text.
/// Stable category for a PAM status without its free-form message.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PamReason {
AuthenticationRejected,
@@ -72,12 +70,12 @@ impl fmt::Display for PamReason {
}
}
/// A structured event whose fields are safe to send to an audit logger.
/// Event fields intended for the audit logger.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AuditEvent<'a> {
pub action: AuditAction,
pub outcome: AuditOutcome,
/// Account name supplied by the user; never a password or token.
/// Account name supplied by the user, never a password or token.
pub account: &'a str,
pub pam_reason: Option<PamReason>,
}
@@ -115,7 +113,7 @@ impl<'a> AuditEvent<'a> {
}
}
/// Maps Linux-PAM status codes to stable, non-sensitive categories.
/// Map Linux-PAM status codes to stable categories.
pub const fn pam_reason(status: i32) -> PamReason {
match status {
6 | 7 => PamReason::AuthenticationRejected,

View File

@@ -13,7 +13,7 @@ pub const SYSTEM_CONFIG_PATH: &str = "/etc/latchd/config.toml";
const PROJECT_CONFIG_DIR: &str = "latchd";
const CONFIG_FILE: &str = "config.toml";
/// Returns the current user's configuration path.
/// Build the current user's configuration path from XDG variables.
pub fn user_config_path() -> Option<PathBuf> {
let base = env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
@@ -184,7 +184,7 @@ pub struct PowerAction {
pub args: Vec<String>,
}
/// Loads a configuration file over the built-in defaults.
/// Load a TOML file over the built-in defaults and validate it.
pub fn load(path: Option<&Path>, variables: Option<&Path>) -> Result<Config, Error> {
let mut config = Config::default();
if let Some(variables_path) = variables {
@@ -231,7 +231,7 @@ fn resolve_variables(input: &str, variables: &HashMap<String, String>) -> String
})
}
/// Validates values that must be safe before starting the login screen.
/// Validate values and paths before the login screen starts.
pub fn validate(config: &Config) -> Result<(), Error> {
if config.tty == 0 || config.tty > 63 {
return Err(Error::Config("tty must be between 1 and 63".into()));
@@ -314,13 +314,13 @@ pub fn validate(config: &Config) -> Result<(), Error> {
Ok(())
}
/// Rejects a privileged configuration source that can be replaced by another user.
/// Require a root-owned regular file without group or other write permission.
pub fn validate_source(path: &Path, label: &str) -> Result<(), Error> {
let metadata =
fs::symlink_metadata(path).io_context(format!("inspect {label} {}", path.display()))?;
if !metadata.is_file() || metadata.uid() != 0 || metadata.mode() & 0o022 != 0 {
return Err(Error::Config(format!(
"{label} must be a root-owned, non-group-writable regular file: {}",
"{label} must be owned by root, non-group-writable regular file: {}",
path.display()
)));
}

View File

@@ -1,39 +1,39 @@
use std::io;
use std::{fmt::Write as _, io};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum Error {
#[error("configuration: {0}")]
#[error("Configuration error: {0}")]
Config(String),
#[error("{context}: {source}")]
#[error("I/O error while {context}: {source}")]
Io {
context: String,
#[source]
source: io::Error,
},
#[error("parse {path}: {source}")]
#[error("TOML error in {path}: {source}")]
Toml {
path: String,
#[source]
source: toml::de::Error,
},
#[error("PAM {operation} failed with status {status}: {message}")]
#[error("PAM error during {operation} (status {status}): {message}")]
Pam {
operation: &'static str,
status: i32,
message: String,
},
#[error("session: {0}")]
#[error("Session error: {0}")]
Session(String),
#[error("terminal: {0}")]
#[error("Terminal error: {0}")]
Terminal(String),
#[error("authentication worker: {0}")]
#[error("Authentication worker error: {0}")]
Worker(String),
}
impl Error {
/// Returns a PAM status when this error came directly from PAM.
/// Return the PAM status when the error came from PAM.
pub fn pam_status(&self) -> Option<i32> {
match self {
Self::Pam { status, .. } => Some(*status),
@@ -41,10 +41,88 @@ impl Error {
}
}
/// Returns whether PAM requires a password change before login can finish.
/// Check whether the status requests a password change.
pub fn requires_password_change(&self) -> bool {
matches!(self.pam_status(), Some(12 | 27))
}
/// Build a user-facing report with context and recovery commands.
pub fn user_report(&self) -> String {
let mut report = format!("{}\n\nDetails: {}\n\nTry:\n", self, self.details());
for fix in self.fixes() {
let _ = writeln!(report, " - {fix}");
}
report
}
fn details(&self) -> &'static str {
match self {
Self::Config(_) => {
"latchd rejected a configuration value before it opened the login screen."
}
Self::Io { .. } => {
"The operating system rejected a file operation. The path, owner, mode, or free space can be wrong."
}
Self::Toml { .. } => {
"The TOML parser found an invalid key, value, or separator at the location shown above."
}
Self::Pam { .. } => {
"The login service returned a PAM status. The status and module message appear above."
}
Self::Session(_) => "latchd could not prepare or start the selected desktop session.",
Self::Terminal(_) => {
"The terminal does not provide the input or output device that latchd needs."
}
Self::Worker(_) => {
"A background latchd task stopped before it completed the requested operation."
}
}
}
fn fixes(&self) -> Vec<String> {
match self {
Self::Config(_) => vec![
"Edit the value named in the error.".into(),
"Run `latchd --preview --no-tui` to check the user configuration.".into(),
"Run `sudo latchd --no-tui` to check the system configuration.".into(),
],
Self::Io { .. } => vec![
"Check the path and permissions named in the error.".into(),
"Run `df -h` to check free space.".into(),
"Run `sudo journalctl -u latchd.service -b` to read the service log.".into(),
],
Self::Toml { path, .. } => {
let path = shell_quote(path);
vec![
format!("Open {path} at the reported line and column."),
format!("Run `latchd --preview --no-tui --config {path}` after the edit."),
]
}
Self::Pam { .. } => vec![
"Run `sudo journalctl -u latchd.service -b` to read the login service log.".into(),
"Run `sudo faillock --user <account>` to check the account lock state.".into(),
"Check the PAM service file with `sudoedit /etc/pam.d/<pam-service>`.".into(),
],
Self::Session(_) => vec![
"Run `latchd --preview --no-tui` to validate the configuration and discovered sessions.".into(),
"Run `sudo journalctl -u latchd.service -b` to read the session log.".into(),
"Check the selected desktop command with `command -v <program>`.".into(),
],
Self::Terminal(_) => vec![
"Run `tty` to confirm that the process has a controlling terminal.".into(),
"Run `latchd --preview` to test the screen without a real login.".into(),
"Run `sudo systemctl restart latchd.service` after you attach latchd to a real VT.".into(),
],
Self::Worker(_) => vec![
"Run `sudo systemctl restart latchd.service` to restart the worker.".into(),
"Run `sudo journalctl -u latchd.service -b` to find the worker failure.".into(),
],
}
}
}
fn shell_quote(value: &str) -> String {
format!("'{}'", value.replace('\'', "'\"'\"'"))
}
pub fn io_error(context: impl Into<String>, source: io::Error) -> Error {
@@ -63,3 +141,27 @@ impl<T> ResultExt<T> for io::Result<T> {
self.map_err(|source| io_error(context, source))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn configuration_report_contains_details_and_commands() {
let report = Error::Config("tty must be between 1 and 63".into()).user_report();
assert!(report.contains("Details:"));
assert!(report.contains("latchd --preview --no-tui"));
assert!(report.contains("sudo latchd --no-tui"));
}
#[test]
fn toml_report_quotes_the_source_path() {
let error = Error::Toml {
path: "/tmp/latchd config.toml".into(),
source: toml::from_str::<toml::Value>("=").expect_err("invalid TOML"),
};
let report = error.user_report();
assert!(report.contains("'/tmp/latchd config.toml'"));
assert!(report.contains("--config '/tmp/latchd config.toml'"));
}
}

View File

@@ -1,4 +1,4 @@
//! Core configuration, account, session, and accounting contracts for latchd.
//! Shared configuration, account, session, and accounting types.
#![deny(unsafe_op_in_unsafe_fn)]

View File

@@ -1,4 +1,4 @@
//! Bounded, owner-checked persistence for last-used session choices.
//! Store the last session choice with private, atomic writes.
use std::{
fs::{self, OpenOptions},
@@ -32,7 +32,7 @@ pub struct LastUsedPreferences {
}
impl LastUsedPreferences {
/// Records a successful selection, moving the newest entry to the front.
/// Record a successful selection and move it to the front.
pub fn remember(&mut self, username: &str, session: &SessionDescriptor) {
if username.is_empty() || session.title.is_empty() {
return;
@@ -49,7 +49,7 @@ impl LastUsedPreferences {
self.entries.truncate(MAX_ENTRIES);
}
/// Finds the exact previously selected session for an account.
/// Find the saved session for an account.
pub fn preferred_index(&self, username: &str, sessions: &[SessionDescriptor]) -> Option<usize> {
let entry = self
.entries
@@ -65,7 +65,7 @@ impl LastUsedPreferences {
}
}
/// Loads preferences from an owner-checked TOML file.
/// Load preferences from an owner-checked TOML file.
pub fn load(path: &Path) -> Result<LastUsedPreferences, Error> {
if !path.is_absolute() {
return Err(Error::Config("last-session path must be absolute".into()));
@@ -93,7 +93,7 @@ pub fn load(path: &Path) -> Result<LastUsedPreferences, Error> {
Ok(preferences)
}
/// Atomically saves preferences with mode `0600` under a trusted directory.
/// Save 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()));

View File

@@ -1,4 +1,4 @@
//! Session discovery, environment construction, and child lifecycle.
//! Discover sessions, build their environments, and run their children.
use std::{
ffi::{CString, OsStr},
@@ -41,7 +41,7 @@ pub enum SessionKind {
pub struct SessionDescriptor {
pub kind: SessionKind,
pub title: String,
/// Program and arguments, without shell interpretation.
/// Program and arguments kept separate from shell parsing.
pub argv: Vec<String>,
pub current_desktop: Option<String>,
pub session_desktop: Option<String>,
@@ -82,7 +82,7 @@ impl SessionSpec {
format!("tty{}", self.tty)
}
/// Returns the selected-session metadata that PAM and logind must see.
/// Return session metadata for the login stack and logind.
pub fn pam_environment(&self) -> impl Iterator<Item = (&str, &str)> {
self.environment
.iter()
@@ -91,7 +91,7 @@ impl SessionSpec {
}
}
/// Discovers configured sessions in stable directory order.
/// Discover configured sessions in sorted directory order.
pub fn discover(config: &Config) -> Vec<SessionDescriptor> {
let mut sessions = Vec::new();
scan_desktop_dir(
@@ -124,7 +124,7 @@ pub fn discover(config: &Config) -> Vec<SessionDescriptor> {
sessions
}
/// Checks configured binaries and discovered session entries without starting them.
/// Check configured binaries and session entries without starting them.
pub fn preflight(config: &Config) -> Result<(), Error> {
let shell = secure_executable(&config.system_shell, "configured system shell")?;
if !shell.is_file() {
@@ -192,7 +192,7 @@ fn secure_executable(path: &Path, label: &str) -> Result<PathBuf, Error> {
Ok(resolved)
}
/// Builds an explicit environment and process specification for one identity.
/// Build the environment and process specification for one account.
pub fn build_spec(
identity: &Identity,
config: &Config,
@@ -328,7 +328,7 @@ pub fn build_spec(
})
}
/// Runs a user session and owns all child process cleanup.
/// Run a user session and clean up its child process.
pub fn run_user_session(identity: &Identity, spec: &SessionSpec) -> Result<(), Error> {
let home_metadata = fs::symlink_metadata(&identity.home)
.map_err(|error| io_error(format!("inspect home {}", identity.home.display()), error))?;