// 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, pub unexpected_greetd_response: &'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", unexpected_greetd_response: "Unerwartete Antwort von greetd", 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", unexpected_greetd_response: "Unexpected response from greetd", 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 { 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))); let result = match lang { Some(ref l) => parse_lang_prefix(l), None => "en".to_string(), }; log::debug!("Detected locale: {result} (source: {})", match lang { Some(_) => "LANG env or locale.conf", None => "default", }); result } /// 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 { 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"); assert!(!s.unexpected_greetd_response.is_empty(), "{locale}: unexpected_greetd_response"); } } // -- 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")); } }