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.
306 lines
9.3 KiB
Rust
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"));
|
|
}
|
|
}
|