Rewrite moonlock from Python to Rust (v0.4.0)

Complete rewrite of the Wayland lockscreen from Python/PyGObject to
Rust/gtk4-rs for memory safety in security-critical PAM code and
consistency with the moonset/moongreet Rust ecosystem.

Modules: main, lockscreen, auth (PAM FFI), fingerprint (fprintd D-Bus),
config, i18n, users, power. 37 unit tests.

Security: PAM conversation callback with Zeroizing password, panic hook
that never unlocks, root check, ext-session-lock-v1 compositor policy,
absolute loginctl path, avatar symlink rejection.
This commit is contained in:
2026-03-27 23:09:54 +01:00
parent 7de3737a61
commit 817a9547ad
41 changed files with 3075 additions and 2264 deletions
+93
View File
@@ -0,0 +1,93 @@
// ABOUTME: Current user detection and avatar loading for the lockscreen.
// ABOUTME: Retrieves user info from the system (nix getuid, AccountsService, ~/.face).
use nix::unistd::{getuid, User as NixUser};
use std::path::{Path, PathBuf};
const DEFAULT_ACCOUNTSSERVICE_DIR: &str = "/var/lib/AccountsService/icons";
const GRESOURCE_PREFIX: &str = "/dev/moonarch/moonlock";
#[derive(Debug, Clone)]
pub struct User {
pub username: String,
pub display_name: String,
pub home: PathBuf,
pub uid: u32,
}
pub fn get_current_user() -> Option<User> {
let uid = getuid();
let nix_user = NixUser::from_uid(uid).ok()??;
let gecos = nix_user.gecos.to_str().unwrap_or("").to_string();
let display_name = if !gecos.is_empty() {
let first = gecos.split(',').next().unwrap_or("");
if first.is_empty() { nix_user.name.clone() } else { first.to_string() }
} else { nix_user.name.clone() };
Some(User { username: nix_user.name, display_name, home: nix_user.dir, uid: uid.as_raw() })
}
pub fn get_avatar_path(home: &Path, username: &str) -> Option<PathBuf> {
get_avatar_path_with(home, username, Path::new(DEFAULT_ACCOUNTSSERVICE_DIR))
}
pub fn get_avatar_path_with(home: &Path, username: &str, accountsservice_dir: &Path) -> Option<PathBuf> {
// ~/.face takes priority
let face = home.join(".face");
if face.exists() && !face.is_symlink() { return Some(face); }
// AccountsService icon
if accountsservice_dir.exists() {
let icon = accountsservice_dir.join(username);
if icon.exists() && !icon.is_symlink() { return Some(icon); }
}
None
}
pub fn get_default_avatar_path() -> String {
format!("{GRESOURCE_PREFIX}/default-avatar.svg")
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test] fn current_user_exists() {
let u = get_current_user();
assert!(u.is_some());
let u = u.unwrap();
assert!(!u.username.is_empty());
}
#[test] fn face_file_priority() {
let dir = tempfile::tempdir().unwrap();
let face = dir.path().join(".face"); fs::write(&face, "img").unwrap();
let icons = dir.path().join("icons"); fs::create_dir(&icons).unwrap();
let icon = icons.join("test"); fs::write(&icon, "img").unwrap();
assert_eq!(get_avatar_path_with(dir.path(), "test", &icons), Some(face));
}
#[test] fn accountsservice_fallback() {
let dir = tempfile::tempdir().unwrap();
let icons = dir.path().join("icons"); fs::create_dir(&icons).unwrap();
let icon = icons.join("test"); fs::write(&icon, "img").unwrap();
assert_eq!(get_avatar_path_with(dir.path(), "test", &icons), Some(icon));
}
#[test] fn no_avatar() {
let dir = tempfile::tempdir().unwrap();
assert!(get_avatar_path_with(dir.path(), "test", Path::new("/nonexistent")).is_none());
}
#[test] fn rejects_symlink() {
let dir = tempfile::tempdir().unwrap();
let real = dir.path().join("real"); fs::write(&real, "x").unwrap();
std::os::unix::fs::symlink(&real, dir.path().join(".face")).unwrap();
assert!(get_avatar_path_with(dir.path(), "test", Path::new("/nonexistent")).is_none());
}
#[test] fn default_avatar_gresource() {
let p = get_default_avatar_path();
assert!(p.contains("moonlock"));
assert!(p.contains("default-avatar.svg"));
}
}