Gaussian blur applied at texture load time when `background-blur` is set in the [appearance] section of moongreet.toml. Blur runs once, result is shared across monitors.
1687 lines
54 KiB
Rust
1687 lines
54 KiB
Rust
// 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 image::imageops;
|
|
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 MAX_WALLPAPER_FILE_SIZE: u64 = 50 * 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<Vec<String>> {
|
|
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 == '-')
|
|
}
|
|
|
|
/// Load the background image as a shared texture (decode once, reuse everywhere).
|
|
/// When `blur_radius` is `Some(sigma)` with sigma > 0, a Gaussian blur is applied.
|
|
pub fn load_background_texture(bg_path: &Path, blur_radius: Option<f32>) -> Option<gdk::Texture> {
|
|
let path_str = bg_path.to_str()?;
|
|
let texture = if bg_path.starts_with("/dev/moonarch/moongreet") {
|
|
match gio::resources_lookup_data(path_str, gio::ResourceLookupFlags::NONE) {
|
|
Ok(bytes) => match gdk::Texture::from_bytes(&bytes) {
|
|
Ok(texture) => Some(texture),
|
|
Err(e) => {
|
|
log::debug!("GResource texture decode error: {e}");
|
|
log::warn!("Failed to decode background texture from GResource {path_str}");
|
|
None
|
|
}
|
|
},
|
|
Err(e) => {
|
|
log::debug!("GResource lookup error: {e}");
|
|
log::warn!("Failed to load background texture from GResource {path_str}");
|
|
None
|
|
}
|
|
}
|
|
} else {
|
|
if let Ok(meta) = std::fs::metadata(bg_path)
|
|
&& meta.len() > MAX_WALLPAPER_FILE_SIZE
|
|
{
|
|
log::warn!(
|
|
"Wallpaper file too large ({} bytes), skipping: {}",
|
|
meta.len(), bg_path.display()
|
|
);
|
|
return None;
|
|
}
|
|
match gdk::Texture::from_filename(bg_path) {
|
|
Ok(texture) => Some(texture),
|
|
Err(e) => {
|
|
log::debug!("Wallpaper load error: {e}");
|
|
log::warn!("Failed to load background texture from {}", bg_path.display());
|
|
None
|
|
}
|
|
}
|
|
}?;
|
|
|
|
match blur_radius {
|
|
Some(sigma) if sigma > 0.0 => Some(apply_blur(&texture, sigma)),
|
|
_ => Some(texture),
|
|
}
|
|
}
|
|
|
|
/// Apply Gaussian blur to a texture and return a blurred texture.
|
|
fn apply_blur(texture: &gdk::Texture, sigma: f32) -> gdk::Texture {
|
|
let width = texture.width() as u32;
|
|
let height = texture.height() as u32;
|
|
let stride = width as usize * 4;
|
|
let mut pixel_data = vec![0u8; stride * height as usize];
|
|
texture.download(&mut pixel_data, stride);
|
|
|
|
let img = image::RgbaImage::from_raw(width, height, pixel_data)
|
|
.expect("pixel buffer size matches texture dimensions");
|
|
let blurred = imageops::blur(&image::DynamicImage::ImageRgba8(img), sigma);
|
|
|
|
let bytes = glib::Bytes::from(blurred.as_raw());
|
|
let mem_texture = gdk::MemoryTexture::new(
|
|
width as i32,
|
|
height as i32,
|
|
gdk::MemoryFormat::B8g8r8a8Premultiplied,
|
|
&bytes,
|
|
stride,
|
|
);
|
|
mem_texture.upcast()
|
|
}
|
|
|
|
/// Create a wallpaper-only window for secondary monitors.
|
|
pub fn create_wallpaper_window(
|
|
texture: &gdk::Texture,
|
|
app: >k::Application,
|
|
) -> gtk::ApplicationWindow {
|
|
let window = gtk::ApplicationWindow::builder()
|
|
.application(app)
|
|
.build();
|
|
window.add_css_class("wallpaper");
|
|
|
|
let background = create_background_picture(texture);
|
|
window.set_child(Some(&background));
|
|
|
|
window
|
|
}
|
|
|
|
/// Create a Picture widget for the wallpaper background from a pre-loaded texture.
|
|
fn create_background_picture(texture: &gdk::Texture) -> gtk::Picture {
|
|
let background = gtk::Picture::for_paintable(texture);
|
|
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<User>,
|
|
avatar_cache: HashMap<String, gdk::Texture>,
|
|
default_avatar_texture: Option<gdk::Texture>,
|
|
failed_attempts: HashMap<String, u32>,
|
|
greetd_sock: Arc<Mutex<Option<UnixStream>>>,
|
|
login_cancelled: Arc<std::sync::atomic::AtomicBool>,
|
|
}
|
|
|
|
/// Create the main greeter window with login UI.
|
|
pub fn create_greeter_window(
|
|
texture: Option<&gdk::Texture>,
|
|
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);
|
|
log::debug!("Greeter window: {} user(s), {} session(s)", all_users.len(), all_sessions.len());
|
|
if let Some(ref theme) = config.gtk_theme {
|
|
log::debug!("GTK theme: {theme}");
|
|
}
|
|
|
|
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
|
|
if let Some(texture) = texture {
|
|
overlay.set_child(Some(&create_background_picture(texture)));
|
|
}
|
|
|
|
// 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<RefCell<GreeterState>>,
|
|
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]);
|
|
log::debug!("Initial user: {} (last_user={:?})", target.username, last_username);
|
|
|
|
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<RefCell<GreeterState>>,
|
|
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,
|
|
) {
|
|
log::debug!("Switching to user: {}", user.username);
|
|
{
|
|
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 {
|
|
log::debug!("Avatar cache hit for {}", user.username);
|
|
avatar_image.set_paintable(Some(&texture));
|
|
} else {
|
|
log::debug!("Avatar cache miss for {}", user.username);
|
|
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<RefCell<GreeterState>>,
|
|
) {
|
|
// Reject oversized files
|
|
if let Ok(meta) = std::fs::metadata(path) {
|
|
if meta.len() > MAX_AVATAR_FILE_SIZE {
|
|
log::debug!("Avatar file too large ({} bytes): {}", meta.len(), path.display());
|
|
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(e) => {
|
|
log::debug!("Failed to load avatar {}: {e}", path.display());
|
|
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<RefCell<GreeterState>>,
|
|
) {
|
|
// Use cached version if available
|
|
{
|
|
let s = state.borrow();
|
|
if let Some(ref texture) = s.default_avatar_texture {
|
|
log::debug!("Default avatar: using cached texture");
|
|
image.set_paintable(Some(texture));
|
|
return;
|
|
}
|
|
}
|
|
log::debug!("Default avatar: tinting SVG from GResource");
|
|
|
|
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<Session> {
|
|
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<RefCell<GreeterState>>) {
|
|
log::debug!("Cancelling pending greetd session");
|
|
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<RefCell<GreeterState>>,
|
|
app: >k::Application,
|
|
error_label: >k::Label,
|
|
password_entry: >k::PasswordEntry,
|
|
session_dropdown: >k::DropDown,
|
|
) {
|
|
log::debug!("Login attempt for user: {}", user.username);
|
|
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
|
|
log::debug!("GREETD_SOCK: {sock_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<Mutex<Option<UnixStream>>>,
|
|
login_cancelled: &Arc<std::sync::atomic::AtomicBool>,
|
|
strings: &Strings,
|
|
) -> Result<LoginResult, String> {
|
|
if login_cancelled.load(std::sync::atomic::Ordering::SeqCst) {
|
|
log::debug!("Login cancelled before connect");
|
|
return Ok(LoginResult::Cancelled);
|
|
}
|
|
|
|
log::debug!("Connecting to greetd socket: {sock_path}");
|
|
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
|
|
log::debug!("Creating greetd session for {username}");
|
|
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") {
|
|
log::debug!("Stale session detected, cancelling and retrying");
|
|
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") {
|
|
let description = response
|
|
.get("description")
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or("");
|
|
let message = if !description.is_empty() && description.len() <= MAX_GREETD_ERROR_LENGTH {
|
|
description.to_string()
|
|
} else {
|
|
strings.auth_failed.to_string()
|
|
};
|
|
return Ok(LoginResult::Error { message });
|
|
}
|
|
}
|
|
|
|
// Step 2: Send password if auth message received
|
|
if response.get("type").and_then(|v| v.as_str()) == Some("auth_message") {
|
|
log::debug!("Sending auth response for {username}");
|
|
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") {
|
|
log::debug!("Auth successful, starting session: {exec_cmd}");
|
|
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") {
|
|
log::info!("Login successful for {username}");
|
|
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<String> {
|
|
load_last_user_from(Path::new(LAST_USER_PATH))
|
|
}
|
|
|
|
fn load_last_user_from(path: &Path) -> Option<String> {
|
|
let content = std::fs::read_to_string(path).ok()?;
|
|
let username = content.trim();
|
|
if is_valid_username(username) {
|
|
log::debug!("Loaded last user: {username}");
|
|
Some(username.to_string())
|
|
} else {
|
|
log::debug!("Invalid last user in {}", path.display());
|
|
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) {
|
|
log::debug!("Saving last user: {username}");
|
|
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<String> {
|
|
load_last_session_from(&Path::new(LAST_SESSION_DIR).join(username))
|
|
}
|
|
|
|
fn load_last_session_from(path: &Path) -> Option<String> {
|
|
let content = std::fs::read_to_string(path).ok()?;
|
|
let name = content.trim();
|
|
if is_valid_session_name(name) {
|
|
log::debug!("Loaded last session: {name}");
|
|
Some(name.to_string())
|
|
} else {
|
|
log::debug!("Invalid last session in {}", path.display());
|
|
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) {
|
|
log::debug!("Saving last session: {session_name}");
|
|
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<F>(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<Mutex<Option<UnixStream>>> {
|
|
Arc::new(Mutex::new(None))
|
|
}
|
|
|
|
fn default_cancelled() -> Arc<std::sync::atomic::AtomicBool> {
|
|
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();
|
|
}
|
|
|
|
// -- load_background_texture tests --
|
|
|
|
#[test]
|
|
fn load_background_texture_missing_file_returns_none() {
|
|
let result = load_background_texture(Path::new("/nonexistent/wallpaper.jpg"), None);
|
|
assert!(result.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn load_background_texture_oversized_file_returns_none() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join("huge.jpg");
|
|
// Create a sparse file that exceeds MAX_WALLPAPER_FILE_SIZE
|
|
let f = std::fs::File::create(&path).unwrap();
|
|
f.set_len(MAX_WALLPAPER_FILE_SIZE + 1).unwrap();
|
|
let result = load_background_texture(&path, None);
|
|
assert!(result.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn load_background_texture_non_utf8_path_returns_none() {
|
|
use std::ffi::OsStr;
|
|
use std::os::unix::ffi::OsStrExt;
|
|
// 0xFF is not valid UTF-8
|
|
let non_utf8 = OsStr::from_bytes(&[0xff, 0xfe, 0xfd]);
|
|
let path = Path::new(non_utf8);
|
|
let result = load_background_texture(path, None);
|
|
assert!(result.is_none());
|
|
}
|
|
}
|