nevaforget 09371b5fd2 fix+perf: audit fixes and GPU blur migration (v0.5.0)
Address all findings from quality, performance, and security audits:
- Filter greetd error descriptions consistently (security)
- Re-enable power buttons after failed action (UX bug)
- Narrow TOCTOU window in avatar loading via symlink_metadata (security)
- Allow @ in usernames for LDAP compatibility
- Eliminate unnecessary Vec allocation in passwd parsing
- Remove dead i18n field, annotate retained-for-future struct fields
- Fix if/if→if/else and noisy test output in power.rs

Replace CPU blur (image crate + disk cache + async orchestration) with
GPU blur via GskBlurNode — symmetric with moonlock and moonset.
Removes ~15 transitive dependencies and ~200 lines of caching code.
2026-03-28 22:34:12 +01:00

306 lines
9.3 KiB
Rust

// ABOUTME: User detection — parses /etc/passwd for login users and finds avatars.
// ABOUTME: Provides User struct and helpers for the greeter UI.
use std::fs;
use std::path::{Path, PathBuf};
const MIN_UID: u32 = 1000;
const MAX_UID: u32 = 65533;
const DEFAULT_PASSWD: &str = "/etc/passwd";
const DEFAULT_ACCOUNTSSERVICE_DIR: &str = "/var/lib/AccountsService/icons";
const GRESOURCE_PREFIX: &str = "/dev/moonarch/moongreet";
/// Shells that indicate a user cannot log in.
const NOLOGIN_SHELLS: &[&str] = &[
"/usr/sbin/nologin",
"/sbin/nologin",
"/bin/false",
"/usr/bin/nologin",
];
/// Represents a system user suitable for login.
#[derive(Debug, Clone)]
pub struct User {
pub username: String,
#[allow(dead_code)] // Retained for debugging and future UID-based features
pub uid: u32,
pub gecos: String,
pub home: PathBuf,
#[allow(dead_code)] // Retained for debugging and future shell-based filtering
pub shell: String,
}
impl User {
/// Return the display name (first GECOS subfield if available, otherwise username).
pub fn display_name(&self) -> &str {
if self.gecos.is_empty() {
&self.username
} else {
self.gecos.split(',').next().unwrap_or(&self.username)
}
}
}
/// Parse /etc/passwd and return users with UID in the login range.
pub fn get_users(passwd_path: Option<&Path>) -> Vec<User> {
let path = passwd_path.unwrap_or(Path::new(DEFAULT_PASSWD));
let content = match fs::read_to_string(path) {
Ok(c) => c,
Err(e) => {
log::warn!("Failed to read passwd file {}: {e}", path.display());
return Vec::new();
}
};
let mut users = Vec::new();
for line in content.lines() {
let mut fields = line.splitn(7, ':');
let (Some(username), Some(_pw), Some(uid_str), Some(_gid), Some(gecos), Some(home), Some(shell)) =
(fields.next(), fields.next(), fields.next(), fields.next(),
fields.next(), fields.next(), fields.next())
else {
continue;
};
let uid = match uid_str.parse::<u32>() {
Ok(u) => u,
Err(_) => continue,
};
if uid < MIN_UID || uid > MAX_UID {
continue;
}
if NOLOGIN_SHELLS.contains(&shell) {
continue;
}
// Path traversal prevention
if username.contains('/') || username.starts_with('.') {
continue;
}
users.push(User {
username: username.to_string(),
uid,
gecos: gecos.to_string(),
home: PathBuf::from(home),
shell: shell.to_string(),
});
}
log::debug!("Found {} login user(s)", users.len());
users
}
/// Find avatar for a user: AccountsService icon > ~/.face > None.
/// Rejects symlinks to prevent path traversal.
pub fn get_avatar_path(username: &str, home: &Path) -> Option<PathBuf> {
get_avatar_path_with(username, home, Path::new(DEFAULT_ACCOUNTSSERVICE_DIR))
}
/// Find avatar with configurable AccountsService dir (for testing).
pub fn get_avatar_path_with(
username: &str,
home: &Path,
accountsservice_dir: &Path,
) -> Option<PathBuf> {
// AccountsService icon takes priority
if accountsservice_dir.exists() {
let icon = accountsservice_dir.join(username);
if let Ok(meta) = icon.symlink_metadata() {
if meta.file_type().is_symlink() {
log::warn!("Rejecting symlink avatar for {username}: {}", icon.display());
} else {
log::debug!("Avatar for {username}: AccountsService {}", icon.display());
return Some(icon);
}
}
}
// ~/.face fallback
let face = home.join(".face");
if let Ok(meta) = face.symlink_metadata() {
if meta.file_type().is_symlink() {
log::warn!("Rejecting symlink avatar for {username}: {}", face.display());
} else {
log::debug!("Avatar for {username}: ~/.face {}", face.display());
return Some(face);
}
}
log::debug!("No avatar found for {username}");
None
}
/// Return the GResource path to the default avatar SVG.
pub fn get_default_avatar_path() -> String {
format!("{GRESOURCE_PREFIX}/default-avatar.svg")
}
#[cfg(test)]
mod tests {
use super::*;
fn make_passwd(dir: &Path, content: &str) -> PathBuf {
let path = dir.join("passwd");
fs::write(&path, content).unwrap();
path
}
#[test]
fn parse_normal_user() {
let dir = tempfile::tempdir().unwrap();
let path = make_passwd(
dir.path(),
"testuser:x:1000:1000:Test User:/home/testuser:/bin/bash\n",
);
let users = get_users(Some(&path));
assert_eq!(users.len(), 1);
assert_eq!(users[0].username, "testuser");
assert_eq!(users[0].uid, 1000);
assert_eq!(users[0].display_name(), "Test User");
assert_eq!(users[0].home, PathBuf::from("/home/testuser"));
}
#[test]
fn gecos_subfield_trimmed() {
let dir = tempfile::tempdir().unwrap();
let path = make_passwd(
dir.path(),
"testuser:x:1000:1000:Test User,Room 123,555-1234:/home/testuser:/bin/bash\n",
);
let users = get_users(Some(&path));
assert_eq!(users[0].display_name(), "Test User");
}
#[test]
fn skip_system_users() {
let dir = tempfile::tempdir().unwrap();
let path = make_passwd(dir.path(), "root:x:0:0:root:/root:/bin/bash\n");
let users = get_users(Some(&path));
assert!(users.is_empty());
}
#[test]
fn skip_nologin_users() {
let dir = tempfile::tempdir().unwrap();
let path = make_passwd(
dir.path(),
"nobody:x:1000:1000::/home/nobody:/usr/sbin/nologin\n",
);
let users = get_users(Some(&path));
assert!(users.is_empty());
}
#[test]
fn skip_users_with_slash_in_name() {
let dir = tempfile::tempdir().unwrap();
let path = make_passwd(
dir.path(),
"bad/user:x:1000:1000::/home/bad:/bin/bash\n",
);
let users = get_users(Some(&path));
assert!(users.is_empty());
}
#[test]
fn skip_users_starting_with_dot() {
let dir = tempfile::tempdir().unwrap();
let path = make_passwd(
dir.path(),
".hidden:x:1000:1000::/home/hidden:/bin/bash\n",
);
let users = get_users(Some(&path));
assert!(users.is_empty());
}
#[test]
fn empty_gecos_uses_username() {
let dir = tempfile::tempdir().unwrap();
let path = make_passwd(
dir.path(),
"testuser:x:1000:1000::/home/testuser:/bin/bash\n",
);
let users = get_users(Some(&path));
assert_eq!(users[0].display_name(), "testuser");
}
#[test]
fn multiple_users() {
let dir = tempfile::tempdir().unwrap();
let path = make_passwd(
dir.path(),
"alice:x:1000:1000:Alice:/home/alice:/bin/bash\n\
bob:x:1001:1001:Bob:/home/bob:/bin/zsh\n",
);
let users = get_users(Some(&path));
assert_eq!(users.len(), 2);
assert_eq!(users[0].username, "alice");
assert_eq!(users[1].username, "bob");
}
#[test]
fn returns_empty_for_missing_file() {
let users = get_users(Some(Path::new("/nonexistent/passwd")));
assert!(users.is_empty());
}
#[test]
fn accountsservice_icon_takes_priority() {
let dir = tempfile::tempdir().unwrap();
let icons_dir = dir.path().join("icons");
fs::create_dir(&icons_dir).unwrap();
let icon = icons_dir.join("testuser");
fs::write(&icon, "fake image").unwrap();
let home = dir.path().join("home");
fs::create_dir(&home).unwrap();
let face = home.join(".face");
fs::write(&face, "fake face").unwrap();
let path = get_avatar_path_with("testuser", &home, &icons_dir);
assert_eq!(path, Some(icon));
}
#[test]
fn face_file_used_when_no_accountsservice() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path().join("home");
fs::create_dir(&home).unwrap();
let face = home.join(".face");
fs::write(&face, "fake face").unwrap();
let path = get_avatar_path_with("testuser", &home, Path::new("/nonexistent"));
assert_eq!(path, Some(face));
}
#[test]
fn returns_none_when_no_avatar() {
let dir = tempfile::tempdir().unwrap();
let path = get_avatar_path_with("testuser", dir.path(), Path::new("/nonexistent"));
assert!(path.is_none());
}
#[test]
fn rejects_symlink_avatar() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path().join("home");
fs::create_dir(&home).unwrap();
let real_file = dir.path().join("real-avatar");
fs::write(&real_file, "fake").unwrap();
std::os::unix::fs::symlink(&real_file, home.join(".face")).unwrap();
let path = get_avatar_path_with("testuser", &home, Path::new("/nonexistent"));
assert!(path.is_none());
}
#[test]
fn default_avatar_path_is_gresource() {
let path = get_default_avatar_path();
assert!(path.contains("default-avatar.svg"));
assert!(path.starts_with("/dev/moonarch/moongreet"));
}
}