feat: ship Rust login manager and GitLab release pipeline
Replace the legacy Go implementation as the production path, quarantine the reference tree, add deterministic release packaging and version metadata, and run verification through GitLab CI.
This commit is contained in:
380
crates/latchd-core/src/config.rs
Normal file
380
crates/latchd-core/src/config.rs
Normal file
@@ -0,0 +1,380 @@
|
||||
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<String>,
|
||||
/// Whether the chooser lists NSS accounts.
|
||||
pub enumerate: bool,
|
||||
/// Absolute faillock helper path.
|
||||
pub faillock_path: PathBuf,
|
||||
/// faillock configuration used to read the deny threshold.
|
||||
pub faillock_config: PathBuf,
|
||||
}
|
||||
|
||||
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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Visual effects used only by the terminal login screen.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct VisualConfig {
|
||||
/// Optional root-owned image used as a low-contrast terminal wallpaper.
|
||||
pub wallpaper_path: Option<PathBuf>,
|
||||
/// Enable the wallpaper and procedural background layer.
|
||||
pub background_enabled: bool,
|
||||
/// Advance animated background and spinner frames.
|
||||
pub animations: bool,
|
||||
/// Show transient in-TUI notifications for user-visible events.
|
||||
pub notifications: bool,
|
||||
/// Disable motion while retaining static visual styling.
|
||||
pub reduced_motion: bool,
|
||||
/// Maximum wallpaper file size accepted before decoding.
|
||||
pub max_wallpaper_bytes: u64,
|
||||
}
|
||||
|
||||
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<PowerAction>,
|
||||
}
|
||||
|
||||
/// A power action represented as an executable argv vector.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PowerAction {
|
||||
/// Displayed key, such as `F1`.
|
||||
pub key: String,
|
||||
/// Displayed label.
|
||||
pub hint: String,
|
||||
/// Executable path.
|
||||
pub program: PathBuf,
|
||||
/// Arguments passed without shell interpretation.
|
||||
#[serde(default)]
|
||||
pub args: Vec<String>,
|
||||
}
|
||||
|
||||
/// Loads a configuration file over the built-in defaults.
|
||||
pub fn load(path: Option<&Path>, variables: Option<&Path>) -> Result<Config, Error> {
|
||||
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<HashMap<String, String>, Error> {
|
||||
let raw = fs::read_to_string(path).io_context(format!("read {}", path.display()))?;
|
||||
let values: HashMap<String, toml::Value> =
|
||||
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, String>) -> 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),
|
||||
] {
|
||||
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::<u8>().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::<Config>("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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user