Rewrite moongreet from Python to Rust (v0.3.0)
Complete rewrite of the greetd greeter from Python/PyGObject to Rust/gtk4-rs for consistency with moonset, single binary without Python runtime, and improved security through Rust memory safety. Modules: main, greeter, ipc, config, i18n, users, sessions, power 86 unit tests covering all modules including login_worker IPC flow. Security hardening: shell-word splitting for exec_cmd, absolute path validation for session binaries, session-name sanitization, absolute loginctl path, atomic IPC writes.
This commit is contained in:
+333
@@ -0,0 +1,333 @@
|
||||
// ABOUTME: Locale detection and string lookup for the greeter UI.
|
||||
// ABOUTME: Reads system locale (LANG or /etc/locale.conf) and provides DE or EN strings.
|
||||
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
const DEFAULT_LOCALE_CONF: &str = "/etc/locale.conf";
|
||||
|
||||
/// All user-visible strings for the greeter UI.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Strings {
|
||||
// UI labels
|
||||
pub password_placeholder: &'static str,
|
||||
pub reboot_tooltip: &'static str,
|
||||
pub shutdown_tooltip: &'static str,
|
||||
|
||||
// Error messages
|
||||
pub no_session_selected: &'static str,
|
||||
pub greetd_sock_not_set: &'static str,
|
||||
pub greetd_sock_not_absolute: &'static str,
|
||||
pub greetd_sock_not_socket: &'static str,
|
||||
pub greetd_sock_unreachable: &'static str,
|
||||
pub auth_failed: &'static str,
|
||||
pub wrong_password: &'static str,
|
||||
pub multi_stage_unsupported: &'static str,
|
||||
pub invalid_session_command: &'static str,
|
||||
pub session_start_failed: &'static str,
|
||||
pub reboot_failed: &'static str,
|
||||
pub shutdown_failed: &'static str,
|
||||
pub connection_error: &'static str,
|
||||
pub socket_error: &'static str,
|
||||
|
||||
// Templates (use .replace("{n}", &count.to_string()))
|
||||
pub faillock_attempts_remaining: &'static str,
|
||||
pub faillock_locked: &'static str,
|
||||
}
|
||||
|
||||
const STRINGS_DE: Strings = Strings {
|
||||
password_placeholder: "Passwort",
|
||||
reboot_tooltip: "Neustart",
|
||||
shutdown_tooltip: "Herunterfahren",
|
||||
no_session_selected: "Keine Session ausgewählt",
|
||||
greetd_sock_not_set: "GREETD_SOCK nicht gesetzt",
|
||||
greetd_sock_not_absolute: "GREETD_SOCK ist kein absoluter Pfad",
|
||||
greetd_sock_not_socket: "GREETD_SOCK zeigt nicht auf einen Socket",
|
||||
greetd_sock_unreachable: "GREETD_SOCK nicht erreichbar",
|
||||
auth_failed: "Authentifizierung fehlgeschlagen",
|
||||
wrong_password: "Falsches Passwort",
|
||||
multi_stage_unsupported: "Mehrstufige Authentifizierung wird nicht unterstützt",
|
||||
invalid_session_command: "Ungültiger Session-Befehl",
|
||||
session_start_failed: "Session konnte nicht gestartet werden",
|
||||
reboot_failed: "Neustart fehlgeschlagen",
|
||||
shutdown_failed: "Herunterfahren fehlgeschlagen",
|
||||
connection_error: "Verbindungsfehler",
|
||||
socket_error: "Socket-Fehler",
|
||||
faillock_attempts_remaining: "Noch {n} Versuch(e) vor Kontosperrung!",
|
||||
faillock_locked: "Konto ist möglicherweise gesperrt",
|
||||
};
|
||||
|
||||
const STRINGS_EN: Strings = Strings {
|
||||
password_placeholder: "Password",
|
||||
reboot_tooltip: "Reboot",
|
||||
shutdown_tooltip: "Shut down",
|
||||
no_session_selected: "No session selected",
|
||||
greetd_sock_not_set: "GREETD_SOCK not set",
|
||||
greetd_sock_not_absolute: "GREETD_SOCK is not an absolute path",
|
||||
greetd_sock_not_socket: "GREETD_SOCK does not point to a socket",
|
||||
greetd_sock_unreachable: "GREETD_SOCK unreachable",
|
||||
auth_failed: "Authentication failed",
|
||||
wrong_password: "Wrong password",
|
||||
multi_stage_unsupported: "Multi-stage authentication is not supported",
|
||||
invalid_session_command: "Invalid session command",
|
||||
session_start_failed: "Failed to start session",
|
||||
reboot_failed: "Reboot failed",
|
||||
shutdown_failed: "Shutdown failed",
|
||||
connection_error: "Connection error",
|
||||
socket_error: "Socket error",
|
||||
faillock_attempts_remaining: "{n} attempt(s) remaining before lockout!",
|
||||
faillock_locked: "Account may be locked",
|
||||
};
|
||||
|
||||
/// Extract the language prefix from a LANG value like "de_DE.UTF-8" → "de".
|
||||
/// Returns "en" for empty, "C", or "POSIX" values.
|
||||
fn parse_lang_prefix(lang: &str) -> String {
|
||||
if lang.is_empty() || lang == "C" || lang == "POSIX" {
|
||||
return "en".to_string();
|
||||
}
|
||||
|
||||
let prefix = lang
|
||||
.split('_')
|
||||
.next()
|
||||
.unwrap_or(lang)
|
||||
.split('.')
|
||||
.next()
|
||||
.unwrap_or(lang)
|
||||
.to_lowercase();
|
||||
|
||||
if prefix.chars().all(|c| c.is_ascii_alphabetic()) && !prefix.is_empty() {
|
||||
prefix
|
||||
} else {
|
||||
"en".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the LANG= value from a locale.conf file.
|
||||
fn read_lang_from_conf(path: &Path) -> Option<String> {
|
||||
let content = fs::read_to_string(path).ok()?;
|
||||
for line in content.lines() {
|
||||
if let Some(value) = line.strip_prefix("LANG=") {
|
||||
let value = value.trim();
|
||||
if !value.is_empty() {
|
||||
return Some(value.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Determine the system language from LANG env var or /etc/locale.conf.
|
||||
pub fn detect_locale() -> String {
|
||||
let lang = env::var("LANG")
|
||||
.ok()
|
||||
.filter(|s| !s.is_empty())
|
||||
.or_else(|| read_lang_from_conf(Path::new(DEFAULT_LOCALE_CONF)));
|
||||
|
||||
match lang {
|
||||
Some(l) => parse_lang_prefix(&l),
|
||||
None => "en".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the string table for the given locale, defaulting to English.
|
||||
pub fn load_strings(locale: Option<&str>) -> &'static Strings {
|
||||
let locale = match locale {
|
||||
Some(l) => l.to_string(),
|
||||
None => detect_locale(),
|
||||
};
|
||||
|
||||
match locale.as_str() {
|
||||
"de" => &STRINGS_DE,
|
||||
_ => &STRINGS_EN,
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a faillock warning for the given attempt count.
|
||||
/// Returns None if no warning is needed yet.
|
||||
pub fn faillock_warning(attempt_count: u32, strings: &Strings) -> Option<String> {
|
||||
const FAILLOCK_MAX_ATTEMPTS: u32 = 3;
|
||||
|
||||
if attempt_count >= FAILLOCK_MAX_ATTEMPTS {
|
||||
return Some(strings.faillock_locked.to_string());
|
||||
}
|
||||
|
||||
let remaining = FAILLOCK_MAX_ATTEMPTS - attempt_count;
|
||||
if remaining == 1 {
|
||||
return Some(
|
||||
strings
|
||||
.faillock_attempts_remaining
|
||||
.replace("{n}", &remaining.to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
|
||||
// -- parse_lang_prefix tests --
|
||||
|
||||
#[test]
|
||||
fn parse_german_locale() {
|
||||
assert_eq!(parse_lang_prefix("de_DE.UTF-8"), "de");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_english_locale() {
|
||||
assert_eq!(parse_lang_prefix("en_US.UTF-8"), "en");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_c_falls_back_to_english() {
|
||||
assert_eq!(parse_lang_prefix("C"), "en");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_posix_falls_back_to_english() {
|
||||
assert_eq!(parse_lang_prefix("POSIX"), "en");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_empty_falls_back_to_english() {
|
||||
assert_eq!(parse_lang_prefix(""), "en");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_unsupported_returns_prefix() {
|
||||
assert_eq!(parse_lang_prefix("fr_FR.UTF-8"), "fr");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_bare_language_code() {
|
||||
assert_eq!(parse_lang_prefix("de"), "de");
|
||||
}
|
||||
|
||||
// -- read_lang_from_conf tests --
|
||||
|
||||
#[test]
|
||||
fn read_conf_extracts_lang() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let conf = dir.path().join("locale.conf");
|
||||
let mut f = fs::File::create(&conf).unwrap();
|
||||
writeln!(f, "LANG=de_DE.UTF-8").unwrap();
|
||||
assert_eq!(read_lang_from_conf(&conf), Some("de_DE.UTF-8".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_conf_returns_none_for_missing_file() {
|
||||
assert_eq!(
|
||||
read_lang_from_conf(Path::new("/nonexistent/locale.conf")),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_conf_returns_none_for_empty_lang() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let conf = dir.path().join("locale.conf");
|
||||
let mut f = fs::File::create(&conf).unwrap();
|
||||
writeln!(f, "LANG=").unwrap();
|
||||
assert_eq!(read_lang_from_conf(&conf), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_conf_skips_non_lang_lines() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let conf = dir.path().join("locale.conf");
|
||||
let mut f = fs::File::create(&conf).unwrap();
|
||||
writeln!(f, "LC_ALL=en_US.UTF-8").unwrap();
|
||||
writeln!(f, "LANG=de_DE.UTF-8").unwrap();
|
||||
assert_eq!(read_lang_from_conf(&conf), Some("de_DE.UTF-8".to_string()));
|
||||
}
|
||||
|
||||
// -- load_strings tests --
|
||||
|
||||
#[test]
|
||||
fn load_strings_german() {
|
||||
let strings = load_strings(Some("de"));
|
||||
assert_eq!(strings.password_placeholder, "Passwort");
|
||||
assert_eq!(strings.reboot_tooltip, "Neustart");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_strings_english() {
|
||||
let strings = load_strings(Some("en"));
|
||||
assert_eq!(strings.password_placeholder, "Password");
|
||||
assert_eq!(strings.reboot_tooltip, "Reboot");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_strings_unknown_falls_back_to_english() {
|
||||
let strings = load_strings(Some("fr"));
|
||||
assert_eq!(strings.password_placeholder, "Password");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_string_fields_nonempty() {
|
||||
for locale in &["de", "en"] {
|
||||
let s = load_strings(Some(locale));
|
||||
assert!(!s.password_placeholder.is_empty(), "{locale}: password_placeholder");
|
||||
assert!(!s.reboot_tooltip.is_empty(), "{locale}: reboot_tooltip");
|
||||
assert!(!s.shutdown_tooltip.is_empty(), "{locale}: shutdown_tooltip");
|
||||
assert!(!s.no_session_selected.is_empty(), "{locale}: no_session_selected");
|
||||
assert!(!s.greetd_sock_not_set.is_empty(), "{locale}: greetd_sock_not_set");
|
||||
assert!(!s.auth_failed.is_empty(), "{locale}: auth_failed");
|
||||
assert!(!s.wrong_password.is_empty(), "{locale}: wrong_password");
|
||||
assert!(!s.reboot_failed.is_empty(), "{locale}: reboot_failed");
|
||||
assert!(!s.shutdown_failed.is_empty(), "{locale}: shutdown_failed");
|
||||
assert!(!s.faillock_attempts_remaining.is_empty(), "{locale}: faillock_attempts_remaining");
|
||||
assert!(!s.faillock_locked.is_empty(), "{locale}: faillock_locked");
|
||||
}
|
||||
}
|
||||
|
||||
// -- faillock_warning tests --
|
||||
|
||||
#[test]
|
||||
fn faillock_no_warning_at_zero_attempts() {
|
||||
let s = load_strings(Some("en"));
|
||||
assert!(faillock_warning(0, s).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn faillock_no_warning_at_first_attempt() {
|
||||
let s = load_strings(Some("en"));
|
||||
assert!(faillock_warning(1, s).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn faillock_warning_at_second_attempt() {
|
||||
let s = load_strings(Some("en"));
|
||||
let warning = faillock_warning(2, s);
|
||||
assert!(warning.is_some());
|
||||
assert!(warning.unwrap().contains("1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn faillock_locked_at_third_attempt() {
|
||||
let s = load_strings(Some("en"));
|
||||
let warning = faillock_warning(3, s);
|
||||
assert!(warning.is_some());
|
||||
assert_eq!(warning.unwrap(), "Account may be locked");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn faillock_locked_beyond_max() {
|
||||
let s = load_strings(Some("en"));
|
||||
let warning = faillock_warning(5, s);
|
||||
assert!(warning.is_some());
|
||||
assert_eq!(warning.unwrap(), "Account may be locked");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn faillock_german_strings() {
|
||||
let s = load_strings(Some("de"));
|
||||
let warning = faillock_warning(2, s).unwrap();
|
||||
assert!(warning.contains("Kontosperrung"));
|
||||
let locked = faillock_warning(3, s).unwrap();
|
||||
assert!(locked.contains("gesperrt"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user