- restart_verify() now async via spawn_future_local (was blocking main thread) - stop() uses 3s timeout instead of unbounded - load_strings() caches locale detection in OnceLock (was reading /etc/locale.conf on every call) - child_get() replaced with child_value().get() for graceful D-Bus type mismatch handling - Eliminate redundant password clone in auth path (direct move into spawn_blocking) - Add on_exhausted callback: hides fp_label after MAX_FP_ATTEMPTS - Set running=false before on_success callback (prevent double-unlock) - Add 4 unit tests for on_verify_status state machine - Document GLib-GString/CString zeroize limitation in CLAUDE.md
141 lines
5.7 KiB
Rust
141 lines
5.7 KiB
Rust
// ABOUTME: Locale detection and string lookup for the lockscreen 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;
|
|
use std::sync::OnceLock;
|
|
|
|
const DEFAULT_LOCALE_CONF: &str = "/etc/locale.conf";
|
|
|
|
/// Cached locale prefix — detected once, reused for all subsequent calls.
|
|
static CACHED_LOCALE: OnceLock<String> = OnceLock::new();
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct Strings {
|
|
pub password_placeholder: &'static str,
|
|
pub reboot_tooltip: &'static str,
|
|
pub shutdown_tooltip: &'static str,
|
|
pub fingerprint_prompt: &'static str,
|
|
pub fingerprint_success: &'static str,
|
|
pub fingerprint_failed: &'static str,
|
|
pub wrong_password: &'static str,
|
|
pub reboot_failed: &'static str,
|
|
pub shutdown_failed: &'static str,
|
|
pub reboot_confirm: &'static str,
|
|
pub shutdown_confirm: &'static str,
|
|
pub confirm_yes: &'static str,
|
|
pub confirm_no: &'static str,
|
|
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",
|
|
fingerprint_prompt: "Fingerabdruck auflegen zum Entsperren",
|
|
fingerprint_success: "Fingerabdruck erkannt",
|
|
fingerprint_failed: "Fingerabdruck nicht erkannt",
|
|
wrong_password: "Falsches Passwort",
|
|
reboot_failed: "Neustart fehlgeschlagen",
|
|
shutdown_failed: "Herunterfahren fehlgeschlagen",
|
|
reboot_confirm: "Wirklich neu starten?",
|
|
shutdown_confirm: "Wirklich herunterfahren?",
|
|
confirm_yes: "Ja",
|
|
confirm_no: "Abbrechen",
|
|
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",
|
|
fingerprint_prompt: "Place finger on reader to unlock",
|
|
fingerprint_success: "Fingerprint recognized",
|
|
fingerprint_failed: "Fingerprint not recognized",
|
|
wrong_password: "Wrong password",
|
|
reboot_failed: "Reboot failed",
|
|
shutdown_failed: "Shutdown failed",
|
|
reboot_confirm: "Really reboot?",
|
|
shutdown_confirm: "Really shut down?",
|
|
confirm_yes: "Yes",
|
|
confirm_no: "Cancel",
|
|
faillock_attempts_remaining: "{n} attempt(s) remaining before lockout!",
|
|
faillock_locked: "Account may be locked",
|
|
};
|
|
|
|
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() }
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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() }
|
|
}
|
|
|
|
pub fn load_strings(locale: Option<&str>) -> &'static Strings {
|
|
let locale = match locale {
|
|
Some(l) => l,
|
|
None => CACHED_LOCALE.get_or_init(detect_locale),
|
|
};
|
|
match locale { "de" => &STRINGS_DE, _ => &STRINGS_EN }
|
|
}
|
|
|
|
pub fn faillock_warning(attempt_count: u32, max_attempts: u32, strings: &Strings) -> Option<String> {
|
|
if attempt_count >= max_attempts { return Some(strings.faillock_locked.to_string()); }
|
|
let remaining = 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;
|
|
|
|
#[test] fn parse_german() { assert_eq!(parse_lang_prefix("de_DE.UTF-8"), "de"); }
|
|
#[test] fn parse_english() { assert_eq!(parse_lang_prefix("en_US.UTF-8"), "en"); }
|
|
#[test] fn parse_c() { assert_eq!(parse_lang_prefix("C"), "en"); }
|
|
#[test] fn parse_empty() { assert_eq!(parse_lang_prefix(""), "en"); }
|
|
|
|
#[test] fn read_conf() {
|
|
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 strings_de() { let s = load_strings(Some("de")); assert_eq!(s.password_placeholder, "Passwort"); }
|
|
#[test] fn strings_en() { let s = load_strings(Some("en")); assert_eq!(s.password_placeholder, "Password"); }
|
|
#[test] fn strings_fallback() { let s = load_strings(Some("fr")); assert_eq!(s.password_placeholder, "Password"); }
|
|
|
|
#[test] fn fingerprint_strings() {
|
|
let s = load_strings(Some("de"));
|
|
assert!(!s.fingerprint_prompt.is_empty());
|
|
assert!(!s.fingerprint_success.is_empty());
|
|
assert!(!s.fingerprint_failed.is_empty());
|
|
}
|
|
|
|
#[test] fn faillock_zero() { assert!(faillock_warning(0, 3, load_strings(Some("en"))).is_none()); }
|
|
#[test] fn faillock_one() { assert!(faillock_warning(1, 3, load_strings(Some("en"))).is_none()); }
|
|
#[test] fn faillock_two() { assert!(faillock_warning(2, 3, load_strings(Some("en"))).is_some()); }
|
|
#[test] fn faillock_three() { assert_eq!(faillock_warning(3, 3, load_strings(Some("en"))).unwrap(), "Account may be locked"); }
|
|
}
|