moonset/src/users.rs
nevaforget 5a6900e85a fix: address audit findings — polling, symlinks, validation, wallpaper removal (v0.7.0)
Three parallel audits (quality, performance, security) identified issues
across the codebase. This commit addresses all remaining findings:

- Replace busy-loop polling in run_command with child.wait() + timeout thread
- Canonicalize ~/.face and AccountsService avatar paths to prevent symlink abuse
- Add detect_locale_with() DI function for testable locale detection
- Move config I/O from activate() to main() to avoid blocking GTK main loop
- Validate background_blur range (0–200), reject invalid values with warning
- Remove embedded wallpaper from GResource — moonarch provides it via filesystem
  (binary size ~3.2MB → ~1.3MB)
2026-03-28 23:09:29 +01:00

154 lines
5.1 KiB
Rust

// ABOUTME: Current user detection and avatar loading for the power menu.
// ABOUTME: Retrieves user info from the system (pwd, AccountsService, ~/.face).
use nix::unistd::{getuid, User as NixUser};
use std::path::{Path, PathBuf};
const DEFAULT_ACCOUNTSSERVICE_DIR: &str = "/var/lib/AccountsService/icons";
/// Represents the current user for the power menu.
#[derive(Debug, Clone)]
pub struct User {
pub username: String,
pub display_name: String,
pub home: PathBuf,
pub uid: u32,
}
/// Get the currently logged-in user's info from the system.
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();
// GECOS field may contain comma-separated values; first field is the full name
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(),
})
}
/// Find the user's avatar image, checking ~/.face then AccountsService.
pub fn get_avatar_path(home: &Path, username: Option<&str>) -> Option<PathBuf> {
get_avatar_path_with(home, username, Path::new(DEFAULT_ACCOUNTSSERVICE_DIR))
}
/// Find avatar with configurable AccountsService dir (for testing).
pub fn get_avatar_path_with(
home: &Path,
username: Option<&str>,
accountsservice_dir: &Path,
) -> Option<PathBuf> {
// ~/.face takes priority — canonicalize to resolve symlinks
let face = home.join(".face");
if face.exists() {
if let Ok(canonical) = std::fs::canonicalize(&face) {
log::debug!("Avatar: using ~/.face ({})", canonical.display());
return Some(canonical);
}
// canonicalize failed (e.g. permissions) — skip rather than return unresolved symlink
log::warn!("Avatar: ~/.face exists but canonicalize failed, skipping");
}
// AccountsService icon — also canonicalize for consistency
if let Some(name) = username {
if accountsservice_dir.exists() {
let icon = accountsservice_dir.join(name);
if icon.exists() {
if let Ok(canonical) = std::fs::canonicalize(&icon) {
log::debug!("Avatar: using AccountsService icon ({})", canonical.display());
return Some(canonical);
}
log::warn!("Avatar: AccountsService icon exists but canonicalize failed, skipping");
}
}
}
None
}
/// Return the GResource path to the default avatar SVG.
pub fn get_default_avatar_path() -> String {
let prefix = crate::GRESOURCE_PREFIX;
format!("{prefix}/default-avatar.svg")
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn get_current_user_returns_some() {
let user = get_current_user();
assert!(user.is_some());
let user = user.unwrap();
assert!(!user.username.is_empty());
assert!(!user.display_name.is_empty());
assert!(user.home.exists());
}
#[test]
fn returns_face_file_if_exists() {
let dir = tempfile::tempdir().unwrap();
let face = dir.path().join(".face");
fs::write(&face, "fake image").unwrap();
let path = get_avatar_path_with(dir.path(), None, Path::new("/nonexistent"));
let expected = fs::canonicalize(&face).unwrap();
assert_eq!(path, Some(expected));
}
#[test]
fn returns_accountsservice_icon_if_exists() {
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 path = get_avatar_path_with(dir.path(), Some("testuser"), &icons_dir);
let expected = fs::canonicalize(&icon).unwrap();
assert_eq!(path, Some(expected));
}
#[test]
fn face_file_takes_priority_over_accountsservice() {
let dir = tempfile::tempdir().unwrap();
let face = dir.path().join(".face");
fs::write(&face, "fake image").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 path = get_avatar_path_with(dir.path(), Some("testuser"), &icons_dir);
let expected = fs::canonicalize(&face).unwrap();
assert_eq!(path, Some(expected));
}
#[test]
fn returns_none_when_no_avatar() {
let dir = tempfile::tempdir().unwrap();
let path = get_avatar_path_with(dir.path(), None, 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/moonset"));
}
}