// 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 { 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::() { 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 { 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 { // 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")); } }