diff --git a/Cargo.lock b/Cargo.lock index 7b4988a..140f644 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -616,7 +616,7 @@ dependencies = [ [[package]] name = "moonset" -version = "0.7.2" +version = "0.8.0" dependencies = [ "dirs", "gdk-pixbuf", diff --git a/Cargo.toml b/Cargo.toml index c716875..c4ba407 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "moonset" -version = "0.7.3" +version = "0.8.0" edition = "2024" description = "Wayland session power menu with GTK4 and Layer Shell" license = "MIT" diff --git a/src/config.rs b/src/config.rs index 2e97f7a..61f6280 100644 --- a/src/config.rs +++ b/src/config.rs @@ -37,8 +37,11 @@ pub fn load_config(config_paths: Option<&[PathBuf]>) -> Config { if parsed.background_path.is_some() { merged.background_path = parsed.background_path; } - if parsed.background_blur.is_some() { + // Validate blur per source — invalid values preserve the previous default + if parsed.background_blur.is_some_and(|b| b.is_finite() && (0.0..=200.0).contains(&b)) { merged.background_blur = parsed.background_blur; + } else if parsed.background_blur.is_some() { + log::warn!("Invalid background_blur in {}, ignoring", path.display()); } } Err(e) => { @@ -48,14 +51,6 @@ pub fn load_config(config_paths: Option<&[PathBuf]>) -> Config { } } - // Validate blur range - if let Some(blur) = merged.background_blur { - if !blur.is_finite() || blur < 0.0 || blur > 200.0 { - log::warn!("Invalid background_blur value {blur}, ignoring"); - merged.background_blur = None; - } - } - merged } @@ -69,12 +64,14 @@ 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 source: config ({})", path.display()); - return Some(path); + if let Ok(meta) = path.symlink_metadata() { + if meta.is_file() && !meta.file_type().is_symlink() { + log::debug!("Wallpaper source: config ({})", path.display()); + return Some(path); + } } } diff --git a/src/panel.rs b/src/panel.rs index b73965e..0f108c2 100644 --- a/src/panel.rs +++ b/src/panel.rs @@ -103,11 +103,18 @@ pub fn load_background_texture(bg_path: Option<&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, @@ -116,17 +123,29 @@ 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); + 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(0.0, 0.0, w + 2.0 * pad, h + 2.0 * pad)); + snapshot.append_texture(texture, &graphene_rs::Rect::new(-pad, -pad, w + 2.0 * pad, h + 2.0 * pad)); snapshot.pop(); // blur snapshot.pop(); // clip diff --git a/src/users.rs b/src/users.rs index dd812f1..7f8ecc5 100644 --- a/src/users.rs +++ b/src/users.rs @@ -47,32 +47,34 @@ pub fn get_avatar_path(home: &Path, username: Option<&str>) -> Option { } /// Find avatar with configurable AccountsService dir (for testing). +/// Rejects symlinks to prevent path traversal. pub fn get_avatar_path_with( home: &Path, username: Option<&str>, accountsservice_dir: &Path, ) -> Option { - // ~/.face takes priority — canonicalize to resolve symlinks + // ~/.face takes priority let face = home.join(".face"); - if face.exists() { - if let Ok(canonical) = std::fs::canonicalize(&face) { - log::debug!("Avatar: using ~/.face ({})", canonical.display()); - return Some(canonical); + if let Ok(meta) = face.symlink_metadata() { + if meta.file_type().is_symlink() { + log::warn!("Rejecting symlink avatar: {}", face.display()); + } else if meta.is_file() { + log::debug!("Avatar: using ~/.face ({})", face.display()); + return Some(face); } - // canonicalize failed (e.g. permissions) — skip rather than return unresolved symlink - log::warn!("Avatar: ~/.face exists but canonicalize failed, skipping"); } - // AccountsService icon — also canonicalize for consistency + // AccountsService icon fallback if let Some(name) = username { if accountsservice_dir.exists() { let icon = accountsservice_dir.join(name); - if icon.exists() { - if let Ok(canonical) = std::fs::canonicalize(&icon) { - log::debug!("Avatar: using AccountsService icon ({})", canonical.display()); - return Some(canonical); + if let Ok(meta) = icon.symlink_metadata() { + if meta.file_type().is_symlink() { + log::warn!("Rejecting symlink avatar: {}", icon.display()); + } else if meta.is_file() { + log::debug!("Avatar: using AccountsService icon ({})", icon.display()); + return Some(icon); } - log::warn!("Avatar: AccountsService icon exists but canonicalize failed, skipping"); } } } @@ -107,8 +109,7 @@ mod tests { let face = dir.path().join(".face"); fs::write(&face, "fake image").unwrap(); let path = get_avatar_path_with(dir.path(), None, Path::new("/nonexistent")); - let expected = fs::canonicalize(&face).unwrap(); - assert_eq!(path, Some(expected)); + assert_eq!(path, Some(face)); } #[test] @@ -119,8 +120,7 @@ mod tests { let icon = icons_dir.join("testuser"); fs::write(&icon, "fake image").unwrap(); let path = get_avatar_path_with(dir.path(), Some("testuser"), &icons_dir); - let expected = fs::canonicalize(&icon).unwrap(); - assert_eq!(path, Some(expected)); + assert_eq!(path, Some(icon)); } #[test] @@ -133,8 +133,18 @@ mod tests { let icon = icons_dir.join("testuser"); fs::write(&icon, "fake image").unwrap(); let path = get_avatar_path_with(dir.path(), Some("testuser"), &icons_dir); - let expected = fs::canonicalize(&face).unwrap(); - assert_eq!(path, Some(expected)); + assert_eq!(path, Some(face)); + } + + #[test] + fn rejects_symlink_avatar() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("secret"); + fs::write(&target, "secret content").unwrap(); + let face = dir.path().join(".face"); + std::os::unix::fs::symlink(&target, &face).unwrap(); + let path = get_avatar_path_with(dir.path(), None, Path::new("/nonexistent")); + assert!(path.is_none()); } #[test]