use std::{ collections::HashMap, fs, os::unix::fs::MetadataExt, path::{Path, PathBuf}, }; 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, } impl Default for Config { fn default() -> Self { Self { tty: 2, main_log_path: PathBuf::from("/var/log/latchd.log"), client_log_path: PathBuf::from("/var/log/latchd.client.log"), pam_service: "latchd".into(), system_shell: PathBuf::from("/bin/sh"), initial_path: "/usr/local/sbin:/usr/local/bin:/usr/bin".into(), x11: X11Config::default(), wayland: WaylandConfig::default(), environment_switcher: SwitcherConfig::default(), power_controls: PowerControls::default(), accounts: AccountsConfig::default(), visual: VisualConfig::default(), } } } /// 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, } impl Default for AccountsConfig { fn default() -> Self { Self { minimum_uid: 1000, excluded_users: Vec::new(), enumerate: true, faillock_path: PathBuf::from("/usr/sbin/faillock"), faillock_config: PathBuf::from("/etc/security/faillock.conf"), shadow_path: PathBuf::from("/etc/shadow"), } } } /// 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, } impl Default for VisualConfig { fn default() -> Self { Self { wallpaper_path: None, background_enabled: true, animations: true, notifications: true, reduced_motion: false, max_wallpaper_bytes: 8 * 1024 * 1024, } } } /// 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, } impl Default for X11Config { fn default() -> Self { Self { display: ":1".into(), server_path: "/usr/bin/X".into(), xauth_path: "/usr/bin/xauth".into(), server_timeout_secs: 60, server_log_path: "/var/log/latchd.xorg.log".into(), sessions_path: "/usr/share/xsessions".into(), scripts_path: "/etc/latchd/wms".into(), } } } /// 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, } impl Default for WaylandConfig { fn default() -> Self { Self { sessions_path: "/usr/share/wayland-sessions".into(), scripts_path: "/etc/latchd/wayland".into(), } } } /// 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, } impl Default for SwitcherConfig { fn default() -> Self { Self { include_tty_shell: false, max_display_length: 18, } } } /// 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, } /// Loads a configuration file over the built-in defaults. pub fn load(path: Option<&Path>, variables: Option<&Path>) -> Result { let mut config = Config::default(); if let Some(variables_path) = variables { let values = load_variables(variables_path)?; if let Some(config_path) = path { let raw = fs::read_to_string(config_path) .io_context(format!("read {}", config_path.display()))?; let expanded = resolve_variables(&raw, &values); config = toml::from_str(&expanded).map_err(|source| Error::Toml { path: config_path.display().to_string(), source, })?; } } else if let Some(config_path) = path { let raw = fs::read_to_string(config_path) .io_context(format!("read {}", config_path.display()))?; config = toml::from_str(&raw).map_err(|source| Error::Toml { path: config_path.display().to_string(), source, })?; } validate(&config)?; Ok(config) } fn load_variables(path: &Path) -> Result, Error> { let raw = fs::read_to_string(path).io_context(format!("read {}", path.display()))?; let values: HashMap = toml::from_str(&raw).map_err(|source| Error::Toml { path: path.display().to_string(), source, })?; Ok(values .into_iter() .map(|(key, value)| (key, value.to_string().trim_matches('"').to_owned())) .collect()) } fn resolve_variables(input: &str, variables: &HashMap) -> String { let mut keys: Vec<_> = variables.keys().collect(); keys.sort_by_key(|key| std::cmp::Reverse(key.len())); keys.into_iter().fold(input.to_owned(), |result, key| { result.replace(&format!("${key}"), &variables[key]) }) } /// Validates values that must be safe before starting the login screen. 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())); } if config.pam_service.is_empty() || config.pam_service.contains('\0') { return Err(Error::Config("pam_service must be a non-empty name".into())); } if config.initial_path.is_empty() { return Err(Error::Config("initial_path must not be empty".into())); } if config .initial_path .split(':') .any(|entry| entry.is_empty() || !Path::new(entry).is_absolute()) { return Err(Error::Config( "initial_path must contain only non-empty absolute directories".into(), )); } if config.environment_switcher.max_display_length == 0 { return Err(Error::Config( "environment_switcher.max_display_length must be greater than zero".into(), )); } for (name, path) in [ ("main_log_path", &config.main_log_path), ("client_log_path", &config.client_log_path), ("system_shell", &config.system_shell), ("x11.server_path", &config.x11.server_path), ("x11.xauth_path", &config.x11.xauth_path), ("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"))); } } if config.visual.max_wallpaper_bytes == 0 || config.visual.max_wallpaper_bytes > 64 * 1024 * 1024 { return Err(Error::Config( "visual.max_wallpaper_bytes must be between 1 and 67108864".into(), )); } if let Some(path) = &config.visual.wallpaper_path { if !path.is_absolute() { return Err(Error::Config( "visual.wallpaper_path must be an absolute path".into(), )); } } if config.x11.display.trim().is_empty() || config.x11.server_timeout_secs == 0 { return Err(Error::Config( "x11 display must be non-empty and server_timeout_secs must be greater than zero" .into(), )); } for action in &config.power_controls.entries { let valid_key = action .key .strip_prefix('F') .and_then(|number| number.parse::().ok()) .is_some_and(|number| (1..=12).contains(&number)); if !valid_key || action.hint.is_empty() || !action.program.is_absolute() { return Err(Error::Config(format!( "power action {} must use F1-F12, an absolute program path, and a non-empty label", action.key ))); } } Ok(()) } /// Rejects a privileged configuration source that can be replaced by another user. 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: {}", path.display() ))); } Ok(()) } #[cfg(test)] mod tests { use super::*; #[test] fn variables_are_replaced_longest_first() { let mut vars = HashMap::new(); vars.insert("A".into(), "short".into()); vars.insert("AB".into(), "long".into()); assert_eq!(resolve_variables("$AB/$A", &vars), "long/short"); } #[test] fn defaults_are_valid() { validate(&Config::default()).expect("defaults are static and valid"); } #[test] fn unknown_fields_are_rejected() { assert!(toml::from_str::("unexpected = true").is_err()); } #[test] fn relative_path_entries_are_rejected() { let config = Config { initial_path: "relative:/usr/bin".into(), ..Config::default() }; assert!(validate(&config).is_err()); } }