moonlock/src/i18n.rs
nevaforget 09e0d47a38 fix: audit fixes — async restart_verify, locale caching, panic safety (v0.5.0)
- 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
2026-03-28 10:16:06 +01:00

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"); }
}