Rewrite moongreet from Python to Rust (v0.3.0)
Complete rewrite of the greetd greeter from Python/PyGObject to Rust/gtk4-rs for consistency with moonset, single binary without Python runtime, and improved security through Rust memory safety. Modules: main, greeter, ipc, config, i18n, users, sessions, power 86 unit tests covering all modules including login_worker IPC flow. Security hardening: shell-word splitting for exec_cmd, absolute path validation for session binaries, session-name sanitization, absolute loginctl path, atomic IPC writes.
This commit is contained in:
+280
@@ -0,0 +1,280 @@
|
||||
// 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,
|
||||
pub uid: u32,
|
||||
pub gecos: String,
|
||||
pub home: PathBuf,
|
||||
pub shell: String,
|
||||
}
|
||||
|
||||
impl User {
|
||||
/// Return the display name (GECOS if available, otherwise username).
|
||||
pub fn display_name(&self) -> &str {
|
||||
if self.gecos.is_empty() {
|
||||
&self.username
|
||||
} else {
|
||||
&self.gecos
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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(_) => return Vec::new(),
|
||||
};
|
||||
|
||||
let mut users = Vec::new();
|
||||
|
||||
for line in content.lines() {
|
||||
let parts: Vec<&str> = line.split(':').collect();
|
||||
if parts.len() < 7 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let username = parts[0];
|
||||
let uid_str = parts[2];
|
||||
let gecos = parts[4];
|
||||
let home = parts[5];
|
||||
let shell = parts[6];
|
||||
|
||||
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(),
|
||||
});
|
||||
}
|
||||
|
||||
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 icon.exists() && !icon.is_symlink() {
|
||||
return Some(icon);
|
||||
}
|
||||
}
|
||||
|
||||
// ~/.face fallback
|
||||
let face = home.join(".face");
|
||||
if face.exists() && !face.is_symlink() {
|
||||
return Some(face);
|
||||
}
|
||||
|
||||
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 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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user