// ABOUTME: Main greeter window — builds the GTK4 UI layout for the Moongreet greeter. // ABOUTME: Handles user selection, session choice, password entry, and power actions. use gdk4 as gdk; use gdk_pixbuf::Pixbuf; use glib::clone; use gtk4::prelude::*; use gtk4::{self as gtk, gio}; use std::cell::RefCell; use std::collections::HashMap; use std::os::unix::net::UnixStream; use std::path::{Path, PathBuf}; use std::rc::Rc; use std::sync::{Arc, Mutex}; use crate::config::Config; use crate::i18n::{faillock_warning, load_strings, Strings}; use crate::ipc; use crate::power::{self, PowerError}; use crate::sessions::{self, Session}; use crate::users::{self, User}; const AVATAR_SIZE: i32 = 128; const MAX_AVATAR_FILE_SIZE: u64 = 10 * 1024 * 1024; const LAST_USER_PATH: &str = "/var/cache/moongreet/last-user"; const LAST_SESSION_DIR: &str = "/var/cache/moongreet/last-session"; const MAX_USERNAME_LENGTH: usize = 256; const MAX_SESSION_NAME_LENGTH: usize = 256; const MAX_GREETD_ERROR_LENGTH: usize = 200; /// Split a string into shell words, respecting single and double quotes. /// Returns None if quotes are unbalanced. fn split_shell_words(s: &str) -> Option> { let mut words = Vec::new(); let mut current = String::new(); let mut chars = s.chars().peekable(); let mut in_single = false; let mut in_double = false; while let Some(c) = chars.next() { match c { '\'' if !in_double => { in_single = !in_single; } '"' if !in_single => { in_double = !in_double; } '\\' if in_double => { // In double quotes, backslash escapes the next char match chars.next() { Some(next) => current.push(next), None => return None, // Trailing backslash } } '\\' if !in_single && !in_double => { // Outside quotes, backslash escapes the next char match chars.next() { Some(next) => current.push(next), None => return None, // Trailing backslash } } c if c.is_whitespace() && !in_single && !in_double => { if !current.is_empty() { words.push(std::mem::take(&mut current)); } } _ => { current.push(c); } } } if in_single || in_double { return None; } if !current.is_empty() { words.push(current); } Some(words) } /// Validate a username against safe patterns. fn is_valid_username(name: &str) -> bool { if name.is_empty() || name.len() > MAX_USERNAME_LENGTH { return false; } let first = name.chars().next().unwrap(); if !first.is_ascii_alphanumeric() && first != '_' { return false; } name.chars() .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.' || c == '-') } /// Create a wallpaper-only window for secondary monitors. pub fn create_wallpaper_window( bg_path: &Path, app: >k::Application, ) -> gtk::ApplicationWindow { let window = gtk::ApplicationWindow::builder() .application(app) .build(); window.add_css_class("wallpaper"); let background = create_background_picture(bg_path); window.set_child(Some(&background)); window } /// Create a Picture widget for the wallpaper background. fn create_background_picture(bg_path: &Path) -> gtk::Picture { let background = if bg_path.starts_with("/dev/moonarch/moongreet") { gtk::Picture::for_resource(bg_path.to_str().unwrap_or("")) } else { gtk::Picture::for_filename(bg_path.to_str().unwrap_or("")) }; background.set_content_fit(gtk::ContentFit::Cover); background.set_hexpand(true); background.set_vexpand(true); background } /// Shared mutable state for the greeter UI. struct GreeterState { selected_user: Option, avatar_cache: HashMap, default_avatar_texture: Option, failed_attempts: HashMap, greetd_sock: Arc>>, login_cancelled: Arc, } /// Create the main greeter window with login UI. pub fn create_greeter_window( bg_path: &Path, config: &Config, app: >k::Application, ) -> gtk::ApplicationWindow { let window = gtk::ApplicationWindow::builder() .application(app) .build(); window.add_css_class("greeter"); window.set_default_size(1920, 1080); // Apply GTK theme from config if let Some(ref theme_name) = config.gtk_theme { if !theme_name.is_empty() && theme_name .chars() .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '+' | '.')) { if let Some(settings) = gtk::Settings::default() { settings.set_gtk_theme_name(Some(theme_name)); } } else { log::warn!("Ignoring invalid GTK theme name: {theme_name}"); } } let strings = load_strings(None); let all_users = users::get_users(None); let all_sessions = sessions::get_sessions(None, None); let state = Rc::new(RefCell::new(GreeterState { selected_user: None, avatar_cache: HashMap::new(), default_avatar_texture: None, failed_attempts: HashMap::new(), greetd_sock: Arc::new(Mutex::new(None)), login_cancelled: Arc::new(std::sync::atomic::AtomicBool::new(false)), })); // Root overlay for layering let overlay = gtk::Overlay::new(); window.set_child(Some(&overlay)); // Background wallpaper overlay.set_child(Some(&create_background_picture(bg_path))); // Main layout: 3 rows (top spacer, center login, bottom bar) let main_box = gtk::Box::new(gtk::Orientation::Vertical, 0); main_box.set_hexpand(true); main_box.set_vexpand(true); overlay.add_overlay(&main_box); // Top spacer let top_spacer = gtk::Box::new(gtk::Orientation::Horizontal, 0); top_spacer.set_vexpand(true); main_box.append(&top_spacer); // Center: login box let login_box = gtk::Box::new(gtk::Orientation::Vertical, 12); login_box.add_css_class("login-box"); login_box.set_halign(gtk::Align::Center); login_box.set_valign(gtk::Align::Center); // Avatar let avatar_frame = gtk::Box::new(gtk::Orientation::Horizontal, 0); avatar_frame.set_size_request(AVATAR_SIZE, AVATAR_SIZE); avatar_frame.set_halign(gtk::Align::Center); avatar_frame.set_overflow(gtk::Overflow::Hidden); avatar_frame.add_css_class("avatar"); let avatar_image = gtk::Image::new(); avatar_image.set_pixel_size(AVATAR_SIZE); avatar_frame.append(&avatar_image); login_box.append(&avatar_frame); // Username label let username_label = gtk::Label::new(Some("")); username_label.add_css_class("username-label"); login_box.append(&username_label); // Session dropdown let session_dropdown = gtk::DropDown::builder().build(); session_dropdown.add_css_class("session-dropdown"); session_dropdown.set_hexpand(true); if !all_sessions.is_empty() { let names: Vec<&str> = all_sessions.iter().map(|s| s.name.as_str()).collect(); let string_list = gtk::StringList::new(&names); session_dropdown.set_model(Some(&string_list)); } login_box.append(&session_dropdown); // Password entry let password_entry = gtk::PasswordEntry::builder() .placeholder_text(strings.password_placeholder) .show_peek_icon(true) .hexpand(true) .build(); password_entry.add_css_class("password-entry"); login_box.append(&password_entry); // Error label (hidden by default) let error_label = gtk::Label::new(None); error_label.add_css_class("error-label"); error_label.set_visible(false); login_box.append(&error_label); login_box.set_halign(gtk::Align::Center); main_box.append(&login_box); // Bottom spacer let bottom_spacer = gtk::Box::new(gtk::Orientation::Horizontal, 0); bottom_spacer.set_vexpand(true); main_box.append(&bottom_spacer); // Bottom bar overlay (user list left, power buttons right) let bottom_bar = gtk::Box::new(gtk::Orientation::Horizontal, 0); bottom_bar.set_hexpand(true); bottom_bar.set_margin_start(16); bottom_bar.set_margin_end(16); bottom_bar.set_margin_bottom(16); bottom_bar.set_valign(gtk::Align::End); // User list (left) let user_list_box = gtk::Box::new(gtk::Orientation::Vertical, 0); user_list_box.add_css_class("user-list"); user_list_box.set_halign(gtk::Align::Start); user_list_box.set_valign(gtk::Align::End); let sessions_rc = Rc::new(all_sessions); let users_rc = Rc::new(all_users); for user in users_rc.iter() { let btn = gtk::Button::with_label(user.display_name()); btn.add_css_class("user-list-item"); let user_clone = user.clone(); btn.connect_clicked(clone!( #[weak] avatar_image, #[weak] username_label, #[weak] password_entry, #[weak] error_label, #[weak] session_dropdown, #[weak] window, #[strong] state, #[strong] sessions_rc, move |_| { cancel_pending_session(&state); switch_to_user( &user_clone, &state, &avatar_image, &username_label, &password_entry, &error_label, &session_dropdown, &sessions_rc, &window, ); } )); user_list_box.append(&btn); } bottom_bar.append(&user_list_box); // Spacer between user list and power buttons let bar_spacer = gtk::Box::new(gtk::Orientation::Horizontal, 0); bar_spacer.set_hexpand(true); bottom_bar.append(&bar_spacer); // Power buttons (right) let power_box = gtk::Box::new(gtk::Orientation::Horizontal, 8); power_box.set_halign(gtk::Align::End); power_box.set_valign(gtk::Align::End); let reboot_btn = gtk::Button::new(); reboot_btn.set_icon_name("system-reboot-symbolic"); reboot_btn.add_css_class("power-button"); reboot_btn.set_tooltip_text(Some(strings.reboot_tooltip)); reboot_btn.connect_clicked(clone!( #[weak] error_label, move |btn| { btn.set_sensitive(false); execute_power_action(power::reboot, strings.reboot_failed, &error_label); } )); power_box.append(&reboot_btn); let shutdown_btn = gtk::Button::new(); shutdown_btn.set_icon_name("system-shutdown-symbolic"); shutdown_btn.add_css_class("power-button"); shutdown_btn.set_tooltip_text(Some(strings.shutdown_tooltip)); shutdown_btn.connect_clicked(clone!( #[weak] error_label, move |btn| { btn.set_sensitive(false); execute_power_action(power::shutdown, strings.shutdown_failed, &error_label); } )); power_box.append(&shutdown_btn); bottom_bar.append(&power_box); overlay.add_overlay(&bottom_bar); // Password entry "activate" (Enter key) handler password_entry.connect_activate(clone!( #[strong] state, #[strong] sessions_rc, #[weak] session_dropdown, #[weak] error_label, #[weak] password_entry, #[weak] app, move |entry| { let user = { let s = state.borrow(); s.selected_user.clone() }; let Some(user) = user else { return }; let password = entry.text().to_string(); let session = get_selected_session(&session_dropdown, &sessions_rc); let Some(session) = session else { show_error(&error_label, &password_entry, strings.no_session_selected); return; }; attempt_login( &user, &password, &session, strings, &state, &app, &error_label, &password_entry, &session_dropdown, ); } )); // Keyboard handling — Escape clears password and error let key_controller = gtk::EventControllerKey::new(); key_controller.connect_key_pressed(clone!( #[weak] password_entry, #[weak] error_label, #[upgrade_or] glib::Propagation::Proceed, move |_, keyval, _, _| { if keyval == gdk::Key::Escape { password_entry.set_text(""); error_label.set_visible(false); glib::Propagation::Stop } else { glib::Propagation::Proceed } } )); window.add_controller(key_controller); // Defer initial user selection until realized (for correct theme colors) window.connect_realize(clone!( #[strong] state, #[strong] users_rc, #[strong] sessions_rc, #[weak] avatar_image, #[weak] username_label, #[weak] password_entry, #[weak] error_label, #[weak] session_dropdown, #[weak] window, move |_| { let state = state.clone(); let users_rc = users_rc.clone(); let sessions_rc = sessions_rc.clone(); glib::idle_add_local_once(clone!( #[weak] avatar_image, #[weak] username_label, #[weak] password_entry, #[weak] error_label, #[weak] session_dropdown, #[weak] window, move || { select_initial_user( &users_rc, &state, &avatar_image, &username_label, &password_entry, &error_label, &session_dropdown, &sessions_rc, &window, ); } )); } )); window } /// Select the last user or the first available user. fn select_initial_user( users: &[User], state: &Rc>, avatar_image: >k::Image, username_label: >k::Label, password_entry: >k::PasswordEntry, error_label: >k::Label, session_dropdown: >k::DropDown, sessions: &[Session], window: >k::ApplicationWindow, ) { if users.is_empty() { return; } let last_username = load_last_user(); let target = last_username .as_ref() .and_then(|name| users.iter().find(|u| &u.username == name)) .unwrap_or(&users[0]); switch_to_user( target, state, avatar_image, username_label, password_entry, error_label, session_dropdown, sessions, window, ); } /// Update the UI to show the selected user. fn switch_to_user( user: &User, state: &Rc>, avatar_image: >k::Image, username_label: >k::Label, password_entry: >k::PasswordEntry, error_label: >k::Label, session_dropdown: >k::DropDown, sessions: &[Session], window: >k::ApplicationWindow, ) { { let mut s = state.borrow_mut(); s.selected_user = Some(user.clone()); } username_label.set_text(user.display_name()); password_entry.set_text(""); error_label.set_visible(false); // Update avatar let cached = { let s = state.borrow(); s.avatar_cache.get(&user.username).cloned() }; if let Some(texture) = cached { avatar_image.set_paintable(Some(&texture)); } else { let avatar_path = users::get_avatar_path(&user.username, &user.home); if let Some(path) = avatar_path { // get_avatar_path already checks existence — go straight to loading set_avatar_from_file(avatar_image, &path, Some(&user.username), state); } else { set_default_avatar(avatar_image, window, state); } } // Pre-select last used session for this user select_last_session(&user.username, session_dropdown, sessions); password_entry.grab_focus(); } /// Load an image file and set it as the avatar. fn set_avatar_from_file( image: >k::Image, path: &Path, username: Option<&str>, state: &Rc>, ) { // Reject oversized files if let Ok(meta) = std::fs::metadata(path) { if meta.len() > MAX_AVATAR_FILE_SIZE { image.set_icon_name(Some("avatar-default-symbolic")); return; } } match Pixbuf::from_file_at_scale(path.to_str().unwrap_or(""), AVATAR_SIZE, AVATAR_SIZE, true) { Ok(pixbuf) => { let texture = gdk::Texture::for_pixbuf(&pixbuf); if let Some(name) = username { state .borrow_mut() .avatar_cache .insert(name.to_string(), texture.clone()); } image.set_paintable(Some(&texture)); } Err(_) => { image.set_icon_name(Some("avatar-default-symbolic")); } } } /// Load the default avatar SVG from GResources, tinted with the foreground color. fn set_default_avatar( image: >k::Image, window: >k::ApplicationWindow, state: &Rc>, ) { // Use cached version if available { let s = state.borrow(); if let Some(ref texture) = s.default_avatar_texture { image.set_paintable(Some(texture)); return; } } let resource_path = users::get_default_avatar_path(); if let Ok(bytes) = gio::resources_lookup_data(&resource_path, gio::ResourceLookupFlags::NONE) { let svg_text = String::from_utf8_lossy(&bytes); let rgba = window.color(); let fg_color = format!( "#{:02x}{:02x}{:02x}", (rgba.red() * 255.0) as u8, (rgba.green() * 255.0) as u8, (rgba.blue() * 255.0) as u8, ); let tinted = svg_text.replace("#PLACEHOLDER", &fg_color); let svg_bytes = tinted.as_bytes(); if let Ok(loader) = gdk_pixbuf::PixbufLoader::with_type("svg") { loader.set_size(AVATAR_SIZE, AVATAR_SIZE); if loader.write(svg_bytes).is_ok() { if let Err(e) = loader.close() { log::warn!("Failed to close SVG loader: {e}"); } if let Some(pixbuf) = loader.pixbuf() { let texture = gdk::Texture::for_pixbuf(&pixbuf); state.borrow_mut().default_avatar_texture = Some(texture.clone()); image.set_paintable(Some(&texture)); return; } } } } // Fallback image.set_icon_name(Some("avatar-default-symbolic")); } /// Get the currently selected session from the dropdown. fn get_selected_session( dropdown: >k::DropDown, sessions: &[Session], ) -> Option { if sessions.is_empty() { return None; } let idx = dropdown.selected(); if idx == gtk::INVALID_LIST_POSITION { return None; } sessions.get(idx as usize).cloned() } /// Pre-select the last used session for a user in the dropdown. fn select_last_session( username: &str, dropdown: >k::DropDown, sessions: &[Session], ) { if sessions.is_empty() { return; } let last_name = load_last_session(username); if let Some(name) = last_name { for (i, session) in sessions.iter().enumerate() { if session.name == name { dropdown.set_selected(i as u32); return; } } } } /// Display an error message below the password field. fn show_error( error_label: >k::Label, password_entry: >k::PasswordEntry, message: &str, ) { error_label.set_text(message); error_label.set_visible(true); password_entry.set_text(""); password_entry.grab_focus(); } /// Display a greetd error, using a fallback for missing or oversized descriptions. fn show_greetd_error( error_label: >k::Label, password_entry: >k::PasswordEntry, response: &serde_json::Value, fallback: &str, ) { let description = response .get("description") .and_then(|v| v.as_str()) .unwrap_or(""); if !description.is_empty() && description.len() <= MAX_GREETD_ERROR_LENGTH { show_error(error_label, password_entry, description); } else { show_error(error_label, password_entry, fallback); } } /// Cancel any in-progress greetd session. fn cancel_pending_session(state: &Rc>) { let s = state.borrow(); s.login_cancelled .store(true, std::sync::atomic::Ordering::SeqCst); if let Ok(mut sock_guard) = s.greetd_sock.lock() { if let Some(sock) = sock_guard.take() { let _ = sock.shutdown(std::net::Shutdown::Both); } } } /// Set login controls sensitivity. fn set_login_sensitive( password_entry: >k::PasswordEntry, session_dropdown: >k::DropDown, sensitive: bool, ) { password_entry.set_sensitive(sensitive); session_dropdown.set_sensitive(sensitive); } /// Attempt to authenticate and start a session via greetd IPC. #[allow(clippy::too_many_arguments)] fn attempt_login( user: &User, password: &str, session: &Session, strings: &'static Strings, state: &Rc>, app: >k::Application, error_label: >k::Label, password_entry: >k::PasswordEntry, session_dropdown: >k::DropDown, ) { let sock_path = match std::env::var("GREETD_SOCK") { Ok(p) if !p.is_empty() => p, _ => { show_error(error_label, password_entry, strings.greetd_sock_not_set); return; } }; // Validate socket path let sock_pathbuf = PathBuf::from(&sock_path); if !sock_pathbuf.is_absolute() { show_error( error_label, password_entry, strings.greetd_sock_not_absolute, ); return; } match std::fs::metadata(&sock_pathbuf) { Ok(meta) => { use std::os::unix::fs::FileTypeExt; if !meta.file_type().is_socket() { show_error( error_label, password_entry, strings.greetd_sock_not_socket, ); return; } } Err(_) => { show_error( error_label, password_entry, strings.greetd_sock_unreachable, ); return; } } // Reset cancellation flag and disable UI { let s = state.borrow(); s.login_cancelled .store(false, std::sync::atomic::Ordering::SeqCst); } set_login_sensitive(password_entry, session_dropdown, false); let username = user.username.clone(); let password = password.to_string(); let exec_cmd = session.exec_cmd.clone(); let session_name = session.name.clone(); let greetd_sock = state.borrow().greetd_sock.clone(); let login_cancelled = state.borrow().login_cancelled.clone(); glib::spawn_future_local(clone!( #[weak] app, #[weak] error_label, #[weak] password_entry, #[weak] session_dropdown, #[strong] state, async move { let session_name_clone = session_name.clone(); let result = gio::spawn_blocking(move || { login_worker( &username, &password, &exec_cmd, &sock_path, &greetd_sock, &login_cancelled, strings, ) }) .await; match result { Ok(Ok(LoginResult::Success { username })) => { save_last_user(&username); save_last_session(&username, &session_name_clone); app.quit(); } Ok(Ok(LoginResult::AuthError { response, username: ref uname })) => { let mut s = state.borrow_mut(); let count = s.failed_attempts.entry(uname.clone()).or_insert(0); *count += 1; let warning = faillock_warning(*count, strings); drop(s); show_greetd_error( &error_label, &password_entry, &response, strings.wrong_password, ); if let Some(w) = warning { let current = error_label.text().to_string(); error_label.set_text(&format!("{current}\n{w}")); } set_login_sensitive(&password_entry, &session_dropdown, true); } Ok(Ok(LoginResult::Error { message })) => { show_error(&error_label, &password_entry, &message); set_login_sensitive(&password_entry, &session_dropdown, true); } Ok(Ok(LoginResult::Cancelled)) => { set_login_sensitive(&password_entry, &session_dropdown, true); } Ok(Err(e)) => { log::error!("Login worker error: {e}"); show_error(&error_label, &password_entry, strings.socket_error); set_login_sensitive(&password_entry, &session_dropdown, true); } Err(_) => { log::error!("Login worker panicked"); show_error(&error_label, &password_entry, strings.socket_error); set_login_sensitive(&password_entry, &session_dropdown, true); } } } )); } /// Result of a login attempt from the background thread. enum LoginResult { Success { username: String, }, AuthError { response: serde_json::Value, username: String, }, Error { message: String, }, Cancelled, } /// Run greetd IPC in a background thread. fn login_worker( username: &str, password: &str, exec_cmd: &str, sock_path: &str, greetd_sock: &Arc>>, login_cancelled: &Arc, strings: &Strings, ) -> Result { if login_cancelled.load(std::sync::atomic::Ordering::SeqCst) { return Ok(LoginResult::Cancelled); } let mut sock = UnixStream::connect(sock_path).map_err(|e| e.to_string())?; if let Err(e) = sock.set_read_timeout(Some(std::time::Duration::from_secs(10))) { log::warn!("Failed to set read timeout: {e}"); } if let Err(e) = sock.set_write_timeout(Some(std::time::Duration::from_secs(10))) { log::warn!("Failed to set write timeout: {e}"); } { let mut guard = greetd_sock.lock().map_err(|e| e.to_string())?; *guard = Some(sock.try_clone().map_err(|e| e.to_string())?); } // Step 1: Create session — if a stale session exists, cancel it and retry let mut response = ipc::create_session(&mut sock, username).map_err(|e| e.to_string())?; if login_cancelled.load(std::sync::atomic::Ordering::SeqCst) { return Ok(LoginResult::Cancelled); } if response.get("type").and_then(|v| v.as_str()) == Some("error") { let _ = ipc::cancel_session(&mut sock); response = ipc::create_session(&mut sock, username).map_err(|e| e.to_string())?; if login_cancelled.load(std::sync::atomic::Ordering::SeqCst) { return Ok(LoginResult::Cancelled); } if response.get("type").and_then(|v| v.as_str()) == Some("error") { return Ok(LoginResult::Error { message: response .get("description") .and_then(|v| v.as_str()) .unwrap_or(strings.auth_failed) .to_string(), }); } } // Step 2: Send password if auth message received if response.get("type").and_then(|v| v.as_str()) == Some("auth_message") { response = ipc::post_auth_response(&mut sock, Some(password)).map_err(|e| e.to_string())?; if login_cancelled.load(std::sync::atomic::Ordering::SeqCst) { return Ok(LoginResult::Cancelled); } if response.get("type").and_then(|v| v.as_str()) == Some("error") { let _ = ipc::cancel_session(&mut sock); return Ok(LoginResult::AuthError { response, username: username.to_string(), }); } if response.get("type").and_then(|v| v.as_str()) == Some("auth_message") { // Multi-stage auth is not supported let _ = ipc::cancel_session(&mut sock); return Ok(LoginResult::Error { message: strings.multi_stage_unsupported.to_string(), }); } } // Step 3: Start session if response.get("type").and_then(|v| v.as_str()) == Some("success") { let cmd = match split_shell_words(exec_cmd) { Some(words) if !words.is_empty() => words, _ => { let _ = ipc::cancel_session(&mut sock); return Ok(LoginResult::Error { message: strings.invalid_session_command.to_string(), }); } }; // Validate: reject obviously invalid commands (empty, null bytes, path traversal) // greetd resolves PATH for relative commands like "niri-session" let first = &cmd[0]; if first.is_empty() || first.contains('\0') || first.contains("..") { let _ = ipc::cancel_session(&mut sock); return Ok(LoginResult::Error { message: strings.invalid_session_command.to_string(), }); } response = ipc::start_session(&mut sock, &cmd).map_err(|e| e.to_string())?; if login_cancelled.load(std::sync::atomic::Ordering::SeqCst) { return Ok(LoginResult::Cancelled); } if response.get("type").and_then(|v| v.as_str()) == Some("success") { return Ok(LoginResult::Success { username: username.to_string(), }); } else { return Ok(LoginResult::Error { message: response .get("description") .and_then(|v| v.as_str()) .unwrap_or(strings.session_start_failed) .to_string(), }); } } Ok(LoginResult::Error { message: strings.unexpected_greetd_response.to_string(), }) } /// Execute a power action in a background thread. fn execute_power_action( action_fn: fn() -> Result<(), PowerError>, error_message: &'static str, error_label: >k::Label, ) { glib::spawn_future_local(clone!( #[weak] error_label, async move { let result = gio::spawn_blocking(move || action_fn()).await; match result { Ok(Ok(())) => {} Ok(Err(e)) => { log::error!("Power action failed: {e}"); error_label.set_text(error_message); error_label.set_visible(true); } Err(_) => { log::error!("Power action panicked"); error_label.set_text(error_message); error_label.set_visible(true); } } } )); } // -- Last user/session persistence -- fn load_last_user() -> Option { load_last_user_from(Path::new(LAST_USER_PATH)) } fn load_last_user_from(path: &Path) -> Option { let content = std::fs::read_to_string(path).ok()?; let username = content.trim(); if is_valid_username(username) { Some(username.to_string()) } else { None } } fn save_last_user(username: &str) { save_last_user_to(Path::new(LAST_USER_PATH), username); } fn save_last_user_to(path: &Path, username: &str) { if let Some(parent) = path.parent() { let _ = std::fs::create_dir_all(parent); } use std::os::unix::fs::OpenOptionsExt; use std::io::Write; let _ = std::fs::OpenOptions::new() .create(true) .write(true) .truncate(true) .mode(0o600) .open(path) .and_then(|mut f| f.write_all(username.as_bytes())); } fn load_last_session(username: &str) -> Option { load_last_session_from(&Path::new(LAST_SESSION_DIR).join(username)) } fn load_last_session_from(path: &Path) -> Option { let content = std::fs::read_to_string(path).ok()?; let name = content.trim(); if is_valid_session_name(name) { Some(name.to_string()) } else { None } } /// Validate a session name — printable ASCII, no path separators or null bytes. fn is_valid_session_name(name: &str) -> bool { if name.is_empty() || name.len() > MAX_SESSION_NAME_LENGTH { return false; } // Reject path traversal characters and control chars name.chars().all(|c| c >= ' ' && c != '/' && c != '\\' && c != '\0') && !name.contains("..") } fn save_last_session(username: &str, session_name: &str) { if !is_valid_username(username) { return; } if !is_valid_session_name(session_name) { return; } let dir = Path::new(LAST_SESSION_DIR); let _ = std::fs::create_dir_all(dir); save_last_session_to(&dir.join(username), session_name); } fn save_last_session_to(path: &Path, session_name: &str) { use std::os::unix::fs::OpenOptionsExt; use std::io::Write; let _ = std::fs::OpenOptions::new() .create(true) .write(true) .truncate(true) .mode(0o600) .open(path) .and_then(|mut f| f.write_all(session_name.as_bytes())); } #[cfg(test)] mod tests { use super::*; #[test] fn valid_usernames() { assert!(is_valid_username("testuser")); assert!(is_valid_username("test_user")); assert!(is_valid_username("test-user")); assert!(is_valid_username("test.user")); assert!(is_valid_username("_admin")); } #[test] fn invalid_usernames() { assert!(!is_valid_username("")); assert!(!is_valid_username(".hidden")); assert!(!is_valid_username("-dash")); assert!(!is_valid_username("user/name")); assert!(!is_valid_username(&"a".repeat(MAX_USERNAME_LENGTH + 1))); } #[test] fn valid_session_names() { assert!(is_valid_session_name("Niri")); assert!(is_valid_session_name("Hyprland")); assert!(is_valid_session_name("sway (wayland)")); assert!(is_valid_session_name("i3 - X11")); } #[test] fn invalid_session_names() { assert!(!is_valid_session_name("")); assert!(!is_valid_session_name("../../../etc/evil")); assert!(!is_valid_session_name("name/with/slash")); assert!(!is_valid_session_name("name\0null")); assert!(!is_valid_session_name("name\\backslash")); assert!(!is_valid_session_name(&"a".repeat(MAX_SESSION_NAME_LENGTH + 1))); } #[test] fn last_user_roundtrip() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("last-user"); save_last_user_to(&path, "alice"); let loaded = load_last_user_from(&path); assert_eq!(loaded, Some("alice".to_string())); } #[test] fn last_user_rejects_invalid() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("last-user"); save_last_user_to(&path, "../evil"); let loaded = load_last_user_from(&path); assert_eq!(loaded, None); } #[test] fn last_user_missing_file() { let loaded = load_last_user_from(Path::new("/nonexistent/last-user")); assert_eq!(loaded, None); } #[test] fn last_session_roundtrip() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("alice"); save_last_session_to(&path, "Niri"); let loaded = load_last_session_from(&path); assert_eq!(loaded, Some("Niri".to_string())); } #[test] fn last_session_rejects_invalid() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("alice"); save_last_session_to(&path, "../../../etc/evil"); let loaded = load_last_session_from(&path); assert_eq!(loaded, None); } #[test] fn last_session_missing_file() { let loaded = load_last_session_from(Path::new("/nonexistent/session")); assert_eq!(loaded, None); } #[test] fn last_user_file_permissions() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("last-user"); save_last_user_to(&path, "alice"); let meta = std::fs::metadata(&path).unwrap(); use std::os::unix::fs::PermissionsExt; assert_eq!(meta.permissions().mode() & 0o777, 0o600); } #[test] fn last_session_file_permissions() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("session"); save_last_session_to(&path, "Niri"); let meta = std::fs::metadata(&path).unwrap(); use std::os::unix::fs::PermissionsExt; assert_eq!(meta.permissions().mode() & 0o777, 0o600); } // -- split_shell_words tests -- #[test] fn shell_words_simple() { assert_eq!( split_shell_words("niri-session"), Some(vec!["niri-session".to_string()]) ); } #[test] fn shell_words_with_args() { assert_eq!( split_shell_words("sway --config /etc/sway/config"), Some(vec![ "sway".to_string(), "--config".to_string(), "/etc/sway/config".to_string(), ]) ); } #[test] fn shell_words_double_quotes() { assert_eq!( split_shell_words(r#"niri --config "/path with spaces/config""#), Some(vec![ "niri".to_string(), "--config".to_string(), "/path with spaces/config".to_string(), ]) ); } #[test] fn shell_words_single_quotes() { assert_eq!( split_shell_words("bash -c 'echo hello world'"), Some(vec![ "bash".to_string(), "-c".to_string(), "echo hello world".to_string(), ]) ); } #[test] fn shell_words_unbalanced_quotes() { assert!(split_shell_words("niri --config \"unclosed").is_none()); assert!(split_shell_words("bash -c 'unclosed").is_none()); } #[test] fn shell_words_empty() { assert_eq!(split_shell_words(""), Some(vec![])); assert_eq!(split_shell_words(" "), Some(vec![])); } #[test] fn shell_words_backslash_escape() { assert_eq!( split_shell_words(r"path\ with\ spaces"), Some(vec!["path with spaces".to_string()]) ); } #[test] fn shell_words_trailing_backslash() { assert_eq!(split_shell_words(r"foo\"), None); assert_eq!(split_shell_words(r#""foo\"#), None); } // -- login_worker tests -- // These use a real Unix socket pair via UnixListener to simulate greetd. use std::os::unix::net::UnixListener; use crate::ipc; /// Helper: spawn a fake greetd server that responds to messages. fn fake_greetd(handler: F) -> (String, std::thread::JoinHandle<()>) where F: FnOnce(&mut UnixStream) + Send + 'static, { let dir = tempfile::tempdir().unwrap(); let sock_path = dir.path().join("greetd.sock"); let sock_path_str = sock_path.to_str().unwrap().to_string(); let listener = UnixListener::bind(&sock_path).unwrap(); let handle = std::thread::spawn(move || { let (mut client, _) = listener.accept().unwrap(); handler(&mut client); // Keep dir alive until handler finishes drop(dir); }); (sock_path_str, handle) } fn default_greetd_sock() -> Arc>> { Arc::new(Mutex::new(None)) } fn default_cancelled() -> Arc { Arc::new(std::sync::atomic::AtomicBool::new(false)) } #[test] fn login_worker_auth_error() { let (sock_path, handle) = fake_greetd(|stream| { // create_session request let _msg = ipc::recv_message(stream).unwrap(); // Respond with auth_message ipc::send_message(stream, &serde_json::json!({ "type": "auth_message", "auth_message_type": "secret", "auth_message": "Password: ", })).unwrap(); // post_auth_response let _msg = ipc::recv_message(stream).unwrap(); // Respond with error (wrong password) ipc::send_message(stream, &serde_json::json!({ "type": "error", "description": "Authentication failure", })).unwrap(); // cancel_session let _msg = ipc::recv_message(stream).unwrap(); ipc::send_message(stream, &serde_json::json!({"type": "success"})).unwrap(); }); let result = login_worker( "alice", "wrongpass", "/usr/bin/niri", &sock_path, &default_greetd_sock(), &default_cancelled(), load_strings(Some("en")), ); let result = result.unwrap(); assert!(matches!(result, LoginResult::AuthError { .. })); handle.join().unwrap(); } #[test] fn login_worker_stale_session_retry() { let (sock_path, handle) = fake_greetd(|stream| { // First create_session fails (stale session) let _msg = ipc::recv_message(stream).unwrap(); ipc::send_message(stream, &serde_json::json!({ "type": "error", "description": "session already in progress", })).unwrap(); // cancel_session let _msg = ipc::recv_message(stream).unwrap(); ipc::send_message(stream, &serde_json::json!({"type": "success"})).unwrap(); // Retry create_session succeeds let _msg = ipc::recv_message(stream).unwrap(); ipc::send_message(stream, &serde_json::json!({ "type": "auth_message", "auth_message_type": "secret", "auth_message": "Password: ", })).unwrap(); // post_auth_response let _msg = ipc::recv_message(stream).unwrap(); ipc::send_message(stream, &serde_json::json!({"type": "success"})).unwrap(); // start_session let _msg = ipc::recv_message(stream).unwrap(); ipc::send_message(stream, &serde_json::json!({"type": "success"})).unwrap(); }); let result = login_worker( "alice", "correct", "/usr/bin/bash", &sock_path, &default_greetd_sock(), &default_cancelled(), load_strings(Some("en")), ); let result = result.unwrap(); assert!(matches!(result, LoginResult::Success { .. })); handle.join().unwrap(); } #[test] fn login_worker_multi_stage_rejected() { let (sock_path, handle) = fake_greetd(|stream| { // create_session let _msg = ipc::recv_message(stream).unwrap(); ipc::send_message(stream, &serde_json::json!({ "type": "auth_message", "auth_message_type": "secret", "auth_message": "Password: ", })).unwrap(); // post_auth_response → another auth_message (TOTP) let _msg = ipc::recv_message(stream).unwrap(); ipc::send_message(stream, &serde_json::json!({ "type": "auth_message", "auth_message_type": "visible", "auth_message": "TOTP: ", })).unwrap(); // cancel_session let _msg = ipc::recv_message(stream).unwrap(); ipc::send_message(stream, &serde_json::json!({"type": "success"})).unwrap(); }); let result = login_worker( "alice", "pass", "/usr/bin/niri", &sock_path, &default_greetd_sock(), &default_cancelled(), load_strings(Some("en")), ); let result = result.unwrap(); assert!(matches!(result, LoginResult::Error { .. })); if let LoginResult::Error { message } = result { assert!(message.contains("Multi-stage")); } handle.join().unwrap(); } #[test] fn login_worker_start_session_failure() { let (sock_path, handle) = fake_greetd(|stream| { // create_session let _msg = ipc::recv_message(stream).unwrap(); ipc::send_message(stream, &serde_json::json!({ "type": "auth_message", "auth_message_type": "secret", "auth_message": "Password: ", })).unwrap(); // post_auth_response → success let _msg = ipc::recv_message(stream).unwrap(); ipc::send_message(stream, &serde_json::json!({"type": "success"})).unwrap(); // start_session → error let _msg = ipc::recv_message(stream).unwrap(); ipc::send_message(stream, &serde_json::json!({ "type": "error", "description": "session start failed", })).unwrap(); }); let result = login_worker( "alice", "pass", "/usr/bin/bash", &sock_path, &default_greetd_sock(), &default_cancelled(), load_strings(Some("en")), ); let result = result.unwrap(); assert!(matches!(result, LoginResult::Error { .. })); handle.join().unwrap(); } #[test] fn login_worker_cancelled_before_start() { let cancelled = Arc::new(std::sync::atomic::AtomicBool::new(true)); // No server needed — should return Cancelled immediately let result = login_worker( "alice", "pass", "/usr/bin/niri", "/nonexistent/sock", &default_greetd_sock(), &cancelled, load_strings(Some("en")), ); let result = result.unwrap(); assert!(matches!(result, LoginResult::Cancelled)); } #[test] fn login_worker_invalid_exec_cmd() { let (sock_path, handle) = fake_greetd(|stream| { // create_session let _msg = ipc::recv_message(stream).unwrap(); ipc::send_message(stream, &serde_json::json!({ "type": "auth_message", "auth_message_type": "secret", "auth_message": "Password: ", })).unwrap(); // post_auth_response → success let _msg = ipc::recv_message(stream).unwrap(); ipc::send_message(stream, &serde_json::json!({"type": "success"})).unwrap(); // cancel_session (from invalid exec_cmd with path traversal) let _msg = ipc::recv_message(stream).unwrap(); ipc::send_message(stream, &serde_json::json!({"type": "success"})).unwrap(); }); // exec_cmd with path traversal let result = login_worker( "alice", "pass", "../../../etc/evil", &sock_path, &default_greetd_sock(), &default_cancelled(), load_strings(Some("en")), ); let result = result.unwrap(); assert!(matches!(result, LoginResult::Error { .. })); handle.join().unwrap(); } #[test] fn login_worker_relative_exec_cmd_allowed() { let (sock_path, handle) = fake_greetd(|stream| { // create_session let _msg = ipc::recv_message(stream).unwrap(); ipc::send_message(stream, &serde_json::json!({ "type": "auth_message", "auth_message_type": "secret", "auth_message": "Password: ", })).unwrap(); // post_auth_response → success let _msg = ipc::recv_message(stream).unwrap(); ipc::send_message(stream, &serde_json::json!({"type": "success"})).unwrap(); // start_session with relative command (e.g. niri-session) let _msg = ipc::recv_message(stream).unwrap(); ipc::send_message(stream, &serde_json::json!({"type": "success"})).unwrap(); }); // Relative exec_cmd like "niri-session" should be allowed let result = login_worker( "alice", "pass", "niri-session", &sock_path, &default_greetd_sock(), &default_cancelled(), load_strings(Some("en")), ); let result = result.unwrap(); assert!(matches!(result, LoginResult::Success { .. })); handle.join().unwrap(); } }