diff --git a/Cargo.lock b/Cargo.lock index 7070be1..acfe53b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -569,7 +569,7 @@ dependencies = [ [[package]] name = "moongreet" -version = "0.6.1" +version = "0.7.0" dependencies = [ "gdk-pixbuf", "gdk4", @@ -585,6 +585,7 @@ dependencies = [ "systemd-journal-logger", "tempfile", "toml 0.8.23", + "zeroize", ] [[package]] @@ -1124,6 +1125,12 @@ version = "0.8.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + [[package]] name = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index 7ac6f83..2fcda62 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "moongreet" -version = "0.6.2" +version = "0.7.0" edition = "2024" description = "A greetd greeter for Wayland with GTK4 and Layer Shell" license = "MIT" @@ -16,6 +16,7 @@ toml = "0.8" serde = { version = "1", features = ["derive"] } serde_json = "1" graphene-rs = { version = "0.22", package = "graphene-rs" } +zeroize = { version = "1", features = ["std"] } log = "0.4" systemd-journal-logger = "2.2" diff --git a/src/config.rs b/src/config.rs index 0a9517c..f86a736 100644 --- a/src/config.rs +++ b/src/config.rs @@ -111,14 +111,16 @@ pub fn resolve_background_path(config: &Config) -> Option { /// Resolve with configurable moonarch wallpaper path (for testing). pub fn resolve_background_path_with(config: &Config, moonarch_wallpaper: &Path) -> Option { - // User-configured path + // User-configured path — reject symlinks to prevent path traversal if let Some(ref bg) = config.background_path { let path = PathBuf::from(bg); - if path.is_file() { - log::debug!("Wallpaper: using config path {}", path.display()); - return Some(path); + if let Ok(meta) = path.symlink_metadata() { + if meta.is_file() && !meta.file_type().is_symlink() { + log::debug!("Wallpaper: using config path {}", path.display()); + return Some(path); + } } - log::debug!("Wallpaper: config path {} not found, trying fallbacks", path.display()); + log::debug!("Wallpaper: config path {} not usable, trying fallbacks", path.display()); } // Moonarch ecosystem default diff --git a/src/fingerprint.rs b/src/fingerprint.rs index 74897b0..d38bbc0 100644 --- a/src/fingerprint.rs +++ b/src/fingerprint.rs @@ -10,6 +10,7 @@ const FPRINTD_MANAGER_IFACE: &str = "net.reactivated.Fprint.Manager"; const FPRINTD_DEVICE_IFACE: &str = "net.reactivated.Fprint.Device"; const DBUS_TIMEOUT_MS: i32 = 3000; +const FPRINTD_DEVICE_PREFIX: &str = "/net/reactivated/Fprint/Device/"; /// Lightweight fprintd probe — detects device availability and finger enrollment. /// Does NOT perform verification (that happens through greetd/PAM). @@ -66,6 +67,10 @@ impl FingerprintProbe { if device_path.is_empty() { return; } + if !device_path.starts_with(FPRINTD_DEVICE_PREFIX) { + log::warn!("Unexpected fprintd device path: {device_path}"); + return; + } match gio::DBusProxy::for_bus_future( gio::BusType::System, diff --git a/src/greeter.rs b/src/greeter.rs index 648ded0..196ffab 100644 --- a/src/greeter.rs +++ b/src/greeter.rs @@ -12,6 +12,7 @@ use std::os::unix::net::UnixStream; use std::path::{Path, PathBuf}; use std::rc::Rc; use std::sync::{Arc, Mutex}; +use zeroize::Zeroizing; use crate::config::Config; use crate::i18n::{faillock_warning, load_strings, Strings}; @@ -107,14 +108,18 @@ fn is_valid_gtk_theme(name: &str) -> bool { /// Load background texture from filesystem. pub fn load_background_texture(bg_path: &Path) -> Option { - 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; + if let Ok(meta) = std::fs::symlink_metadata(bg_path) { + if meta.file_type().is_symlink() { + log::warn!("Rejecting symlink wallpaper: {}", bg_path.display()); + return None; + } + if 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), @@ -128,11 +133,18 @@ pub fn load_background_texture(bg_path: &Path) -> Option { // -- GPU blur via GskBlurNode ------------------------------------------------- +/// Maximum texture dimension before downscaling for blur. +/// Keeps GPU work reasonable on 4K+ displays. +const MAX_BLUR_DIMENSION: f32 = 1920.0; + /// Render a blurred texture using the GPU via GskBlurNode. /// /// To avoid edge darkening (blur samples transparent pixels outside bounds), /// the texture is rendered with padding equal to 3x the blur sigma. The blur /// is applied to the padded area, then cropped back to the original size. +/// +/// Large textures (> MAX_BLUR_DIMENSION) are downscaled before blurring to +/// reduce GPU work. The sigma is scaled proportionally. fn render_blurred_texture( widget: &impl IsA, texture: &gdk::Texture, @@ -141,16 +153,28 @@ fn render_blurred_texture( let native = widget.native()?; let renderer = native.renderer()?; - let w = texture.width() as f32; - let h = texture.height() as f32; + let orig_w = texture.width() as f32; + let orig_h = texture.height() as f32; + + // Downscale large textures to reduce GPU blur work + let max_dim = orig_w.max(orig_h); + let scale = if max_dim > MAX_BLUR_DIMENSION { + MAX_BLUR_DIMENSION / max_dim + } else { + 1.0 + }; + let w = (orig_w * scale).round(); + let h = (orig_h * scale).round(); + let scaled_sigma = sigma * scale; + // Padding must cover the blur kernel radius (typically ~3x sigma) - let pad = (sigma * 3.0).ceil(); + let pad = (scaled_sigma * 3.0).ceil(); let snapshot = gtk::Snapshot::new(); - // Clip output to original texture size + // Clip output to scaled texture size snapshot.push_clip(&graphene_rs::Rect::new(pad, pad, w, h)); - snapshot.push_blur(sigma as f64); - // Render texture at native size, shifted so edge pixels fill the padding area + snapshot.push_blur(scaled_sigma as f64); + // Render texture with padding on all sides (edges repeat via oversized bounds) snapshot.append_texture(texture, &graphene_rs::Rect::new(-pad, -pad, w + 2.0 * pad, h + 2.0 * pad)); snapshot.pop(); // blur snapshot.pop(); // clip @@ -477,7 +501,7 @@ pub fn create_greeter_window( }; let Some(user) = user else { return }; - let password = entry.text().to_string(); + let password = Zeroizing::new(entry.text().to_string()); let session = get_selected_session(&session_dropdown, &sessions_rc); let Some(session) = session else { @@ -884,15 +908,19 @@ fn extract_greetd_description<'a>(response: &'a serde_json::Value, fallback: &'a .unwrap_or(fallback) } -/// Display a greetd error, using a fallback for missing or oversized descriptions. +/// Display a greetd error. Logs raw PAM details at debug level, +/// shows only the generic fallback in the UI to avoid leaking system info. fn show_greetd_error( error_label: >k::Label, password_entry: >k::PasswordEntry, response: &serde_json::Value, fallback: &str, ) { - let message = extract_greetd_description(response, fallback); - show_error(error_label, password_entry, message); + let raw = extract_greetd_description(response, fallback); + if raw != fallback { + log::debug!("greetd error detail: {raw}"); + } + show_error(error_label, password_entry, fallback); } /// Cancel any in-progress greetd session. @@ -961,7 +989,7 @@ fn attempt_login( set_login_sensitive(password_entry, session_dropdown, false); let username = user.username.clone(); - let password = password.to_string(); + let password = Zeroizing::new(password.to_string()); let exec_cmd = session.exec_cmd.clone(); let session_name = session.name.clone(); let greetd_sock = state.borrow().greetd_sock.clone(); @@ -1105,8 +1133,11 @@ fn login_worker( return Ok(LoginResult::Cancelled); } if response.get("type").and_then(|v| v.as_str()) == Some("error") { - let message = extract_greetd_description(&response, strings.auth_failed).to_string(); - return Ok(LoginResult::Error { message }); + let raw = extract_greetd_description(&response, strings.auth_failed); + if raw != strings.auth_failed { + log::debug!("greetd error detail: {raw}"); + } + return Ok(LoginResult::Error { message: strings.auth_failed.to_string() }); } } @@ -1194,9 +1225,12 @@ fn login_worker( username: username.to_string(), }); } else { + let raw = extract_greetd_description(&response, strings.session_start_failed); + if raw != strings.session_start_failed { + log::debug!("greetd error detail: {raw}"); + } return Ok(LoginResult::Error { - message: extract_greetd_description(&response, strings.session_start_failed) - .to_string(), + message: strings.session_start_failed.to_string(), }); } } diff --git a/src/i18n.rs b/src/i18n.rs index b5f7929..7280d78 100644 --- a/src/i18n.rs +++ b/src/i18n.rs @@ -4,6 +4,7 @@ use std::env; use std::fs; use std::path::Path; +use std::sync::OnceLock; const DEFAULT_LOCALE_CONF: &str = "/etc/locale.conf"; @@ -129,14 +130,17 @@ pub fn detect_locale() -> String { result } +/// Cached locale — detected once, reused for the lifetime of the process. +static CACHED_LOCALE: OnceLock = OnceLock::new(); + /// Return the string table for the given locale, defaulting to English. pub fn load_strings(locale: Option<&str>) -> &'static Strings { let locale = match locale { - Some(l) => l.to_string(), - None => detect_locale(), + Some(l) => l, + None => CACHED_LOCALE.get_or_init(detect_locale), }; - match locale.as_str() { + match locale { "de" => &STRINGS_DE, _ => &STRINGS_EN, } diff --git a/src/users.rs b/src/users.rs index 7dd5a29..56f4c93 100644 --- a/src/users.rs +++ b/src/users.rs @@ -94,7 +94,7 @@ pub fn get_users(passwd_path: Option<&Path>) -> Vec { users } -/// Find avatar for a user: AccountsService icon > ~/.face > None. +/// Find avatar for a user: ~/.face > AccountsService icon > None. /// Rejects symlinks to prevent path traversal. pub fn get_avatar_path(username: &str, home: &Path) -> Option { get_avatar_path_with(username, home, Path::new(DEFAULT_ACCOUNTSSERVICE_DIR)) @@ -106,30 +106,30 @@ pub fn get_avatar_path_with( home: &Path, accountsservice_dir: &Path, ) -> Option { - // AccountsService icon takes priority + // ~/.face takes priority (consistent with moonlock/moonset) + let face = home.join(".face"); + if let Ok(meta) = face.symlink_metadata() { + if meta.file_type().is_symlink() { + log::warn!("Rejecting symlink avatar for {username}: {}", face.display()); + } else if meta.is_file() { + log::debug!("Avatar for {username}: ~/.face {}", face.display()); + return Some(face); + } + } + + // AccountsService icon fallback if accountsservice_dir.exists() { let icon = accountsservice_dir.join(username); if let Ok(meta) = icon.symlink_metadata() { if meta.file_type().is_symlink() { log::warn!("Rejecting symlink avatar for {username}: {}", icon.display()); - } else { + } else if meta.is_file() { log::debug!("Avatar for {username}: AccountsService {}", icon.display()); return Some(icon); } } } - // ~/.face fallback - let face = home.join(".face"); - if let Ok(meta) = face.symlink_metadata() { - if meta.file_type().is_symlink() { - log::warn!("Rejecting symlink avatar for {username}: {}", face.display()); - } else { - log::debug!("Avatar for {username}: ~/.face {}", face.display()); - return Some(face); - } - } - log::debug!("No avatar found for {username}"); None } @@ -248,7 +248,7 @@ mod tests { } #[test] - fn accountsservice_icon_takes_priority() { + fn face_file_takes_priority_over_accountsservice() { let dir = tempfile::tempdir().unwrap(); let icons_dir = dir.path().join("icons"); fs::create_dir(&icons_dir).unwrap(); @@ -261,7 +261,7 @@ mod tests { fs::write(&face, "fake face").unwrap(); let path = get_avatar_path_with("testuser", &home, &icons_dir); - assert_eq!(path, Some(icon)); + assert_eq!(path, Some(face)); } #[test]