All checks were successful
Update PKGBUILD version / update-pkgver (push) Successful in 1s
- Validate fprintd device path prefix (/net/reactivated/Fprint/Device/) before creating D-Bus proxy (prevents use of unexpected object paths) - faillock_warning now warns at remaining <= 2 attempts (not just == 1), improving UX for higher max_attempts configurations
175 lines
7.1 KiB
Rust
175 lines
7.1 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,
|
|
pub auth_timeout: &'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",
|
|
auth_timeout: "Authentifizierung abgelaufen — bitte erneut versuchen",
|
|
};
|
|
|
|
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",
|
|
auth_timeout: "Authentication timed out — please try again",
|
|
};
|
|
|
|
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 }
|
|
}
|
|
|
|
/// Returns a warning when the user is close to lockout (2 or fewer attempts remaining).
|
|
/// Caller is responsible for handling the locked state (count >= max_attempts).
|
|
pub fn faillock_warning(attempt_count: u32, max_attempts: u32, strings: &Strings) -> Option<String> {
|
|
let remaining = max_attempts.saturating_sub(attempt_count);
|
|
if remaining > 0 && remaining <= 2 {
|
|
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_some()); }
|
|
#[test] fn faillock_two() { assert!(faillock_warning(2, 3, load_strings(Some("en"))).is_some()); }
|
|
#[test] fn faillock_three() { assert!(faillock_warning(3, 3, load_strings(Some("en"))).is_none()); }
|
|
|
|
#[test]
|
|
fn faillock_caller_contract() {
|
|
// Mirrors the lockscreen.rs usage: caller handles count >= max separately,
|
|
// faillock_warning is only called when count < max.
|
|
let max = 3u32;
|
|
let strings = load_strings(Some("en"));
|
|
for count in 0..max {
|
|
let result = faillock_warning(count, max, strings);
|
|
let remaining = max - count;
|
|
if remaining <= 2 {
|
|
assert!(result.is_some(), "should warn at count={count} (remaining={remaining})");
|
|
} else {
|
|
assert!(result.is_none(), "should not warn at count={count}");
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn faillock_warns_progressively_with_higher_max() {
|
|
let strings = load_strings(Some("en"));
|
|
// With max=5: warn at count 3 (rem=2) and count 4 (rem=1), not at 0-2
|
|
assert!(faillock_warning(0, 5, strings).is_none());
|
|
assert!(faillock_warning(2, 5, strings).is_none());
|
|
assert!(faillock_warning(3, 5, strings).is_some());
|
|
assert!(faillock_warning(4, 5, strings).is_some());
|
|
assert!(faillock_warning(5, 5, strings).is_none()); // at max, caller handles lockout
|
|
}
|
|
}
|