Compare commits

..

12 Commits

Author SHA1 Message Date
nevaforget d5e431d37e fix: make setup_logging() resilient to journal logger failure (v0.5.1)
Replace unwrap() calls with match-based error handling that falls back
to eprintln — prevents panic when running outside a systemd session.
Consistent with moonlock's logging init pattern.
2026-03-28 22:56:39 +01:00
nevaforget 7c10516473 fix: re-audit findings — avatar path safety, persistence logging, tests
- Reject non-UTF-8 avatar paths early instead of passing empty string to GDK
- Log persistence write failures with warn! instead of silently discarding
- Reduce API surface: create_background_picture pub→fn
- Add boundary test for MAX_USERNAME_LENGTH and socket connect failure test
2026-03-28 22:47:21 +01:00
nevaforget 09371b5fd2 fix+perf: audit fixes and GPU blur migration (v0.5.0)
Address all findings from quality, performance, and security audits:
- Filter greetd error descriptions consistently (security)
- Re-enable power buttons after failed action (UX bug)
- Narrow TOCTOU window in avatar loading via symlink_metadata (security)
- Allow @ in usernames for LDAP compatibility
- Eliminate unnecessary Vec allocation in passwd parsing
- Remove dead i18n field, annotate retained-for-future struct fields
- Fix if/if→if/else and noisy test output in power.rs

Replace CPU blur (image crate + disk cache + async orchestration) with
GPU blur via GskBlurNode — symmetric with moonlock and moonset.
Removes ~15 transitive dependencies and ~200 lines of caching code.
2026-03-28 22:34:12 +01:00
nevaforget 3c39467508 perf: cache blurred wallpaper to disk to avoid re-blur on startup
First launch with blur blurs and saves to /var/cache/moongreet/.
Subsequent starts load the cached PNG directly. Cache invalidates
when wallpaper path, size, mtime, or sigma changes.
2026-03-28 21:23:36 +01:00
nevaforget 64470f99c3 chore: bump version to 0.4.0 2026-03-28 14:55:18 +01:00
nevaforget 293bba32a6 feat: add optional background blur via image crate
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.
2026-03-28 14:53:16 +01:00
nevaforget 14d6476e5a fix: audit findings — wallpaper safety, log filtering, error truncation (v0.4.1)
- Rework load_background_texture(): use resources_lookup_data()/from_bytes()
  for GResource path (no abort on missing resource), add 50 MB file size limit,
  handle non-UTF-8 paths gracefully
- Filter error details to debug level only — warn! logs without internal details
  to prevent system info leaking into journal
- Make debug logging opt-in via MOONGREET_DEBUG env var (default: Info)
- Truncate greetd error description in stale-session retry path using
  MAX_GREETD_ERROR_LENGTH (matching show_greetd_error())
- Add 3 unit tests for load_background_texture edge cases
2026-03-28 10:29:21 +01:00
nevaforget 4c9b436978 fix: wallpaper windows on Layer::Bottom to prevent greeter occlusion
Wallpaper-only windows for secondary monitors were on Layer::Top — same
layer as the greeter window. Since they were created after the greeter,
they occluded the login UI, leaving only the wallpaper visible.
2026-03-28 01:30:22 +01:00
nevaforget 96c94f030a feat: switch to systemd-journal-logger, add debug logging (v0.4.0)
Replace env_logger file-based logging with systemd-journal-logger for
consistency with moonlock and native journalctl integration. Add debug-level
logging at all decision points: config loading, user/session detection,
avatar resolution, locale detection, IPC messages, login flow, and
persistence. No credentials are ever logged.
2026-03-28 01:23:18 +01:00
nevaforget b91e8d47d1 docs: update CLAUDE.md for v0.3.2 audit changes 2026-03-28 00:43:00 +01:00
nevaforget 5db23937ea chore: bump version to 0.3.2 2026-03-28 00:37:51 +01:00
nevaforget 0d4a1b035a fix: audit findings — security, i18n, validation, dead code (v0.3.2)
Quality:
- Q-5: Allow relative session commands (e.g. niri-session), greetd resolves PATH
- Q-3: Socket read+write timeouts with proper error logging
- Q-2: Remove unused PowerError::Timeout variant
- Q-M1: i18n for all login_worker error messages (new: unexpected_greetd_response)
- Q-M2: Explicit INVALID_LIST_POSITION check in session dropdown
- Q-M4: Log SVG loader.close() errors instead of silencing
- Q-M6: Testable persistence functions with proper roundtrip tests

Security:
- S-2: Validate GTK theme name (alphanumeric, _, -, +, . only)
- S-3: Log file created with mode 0o640
- S-4: Cache files (last-user, last-session) created with mode 0o600

Performance:
- P-3: Single symlink_metadata() call instead of exists() + is_symlink()
- P-4: Avoid Vec allocation in IPC send_message (two write_all calls)

Config:
- Update example GTK theme to Colloid-Catppuccin
2026-03-28 00:37:35 +01:00
14 changed files with 614 additions and 342 deletions
+19 -6
View File
@@ -19,6 +19,7 @@ Teil des Moonarch-Ökosystems.
- `src/` — Rust-Quellcode (main.rs, greeter.rs, ipc.rs, config.rs, users.rs, sessions.rs, i18n.rs, power.rs)
- `resources/` — GResource-Assets (style.css, wallpaper.jpg, default-avatar.svg)
- `config/` — Beispiel-Konfigurationsdateien für `/etc/moongreet/` und `/etc/greetd/`
- `pkg/` — PKGBUILD für Arch-Linux-Paketierung (`makepkg -sf`)
## Kommandos
@@ -29,8 +30,11 @@ cargo test
# Release-Build
cargo build --release
# Greeter starten (nur zum Testen, braucht normalerweise greetd)
LD_PRELOAD=/usr/lib/libgtk4-layer-shell.so ./target/release/moongreet
# Greeter im Fenster starten (ohne greetd/Layer Shell)
MOONGREET_NO_LAYER_SHELL=1 ./target/release/moongreet
# Paket bauen und installieren
cd pkg && makepkg -sf && sudo pacman -U moongreet-git-<version>-x86_64.pkg.tar.zst
```
## Architektur
@@ -39,10 +43,10 @@ LD_PRELOAD=/usr/lib/libgtk4-layer-shell.so ./target/release/moongreet
- `users.rs` — Benutzer aus /etc/passwd, Avatare (AccountsService + ~/.face), Symlink-Rejection
- `sessions.rs` — Wayland/X11 Sessions aus .desktop Files
- `power.rs` — Reboot/Shutdown via loginctl
- `i18n.rs` — Locale-Erkennung (LANG / /etc/locale.conf) und String-Tabellen (DE/EN)
- `i18n.rs` — Locale-Erkennung (LANG / /etc/locale.conf) und String-Tabellen (DE/EN), alle UI- und Login-Fehlermeldungen
- `config.rs` — TOML-Config ([appearance] background, gtk-theme) + Wallpaper-Fallback
- `greeter.rs` — GTK4 UI (Overlay-Layout), Login-Flow via greetd IPC, Faillock-Warnung, Avatar-Cache, Last-User/Last-Session Persistence
- `main.rs` — Entry Point, GTK App, Layer Shell Setup, Multi-Monitor
- `greeter.rs` — GTK4 UI (Overlay-Layout), Login-Flow via greetd IPC, Faillock-Warnung, Avatar-Cache, Last-User/Last-Session Persistence (0o600 Permissions)
- `main.rs` — Entry Point, GTK App, Layer Shell Setup, Multi-Monitor, systemd-journal-logger
- `resources/style.css` — Catppuccin-inspiriertes Theme
## Design Decisions
@@ -52,4 +56,13 @@ LD_PRELOAD=/usr/lib/libgtk4-layer-shell.so ./target/release/moongreet
- **Async Login**: `glib::spawn_future_local` + `gio::spawn_blocking` statt raw Threads
- **Socket-Cancellation**: `Arc<Mutex<Option<UnixStream>>>` + `AtomicBool` für saubere Abbrüche
- **Avatar-Cache**: `HashMap<String, gdk::Texture>` in `Rc<RefCell<GreeterState>>`
- **Symmetrie mit moonset**: Gleiche Patterns (i18n, config, users, power, GResource)
- **GPU-Blur via GskBlurNode**: `Snapshot::push_blur()` + `GskRenderer::render_texture()` im `connect_realize` Callback — kein CPU-Blur, kein Disk-Cache, kein `image`-Crate
- **Symmetrie mit moonlock/moonset**: Gleiche Patterns (i18n, config, users, power, GResource, GPU-Blur)
- **Session-Validierung**: Relative Pfade erlaubt (greetd löst PATH auf), nur `..`/Null-Bytes werden abgelehnt
- **GTK-Theme-Validierung**: Nur alphanumerisch + `_-+.` erlaubt, verhindert Path-Traversal über Config
- **Journal-Logging**: `systemd-journal-logger` statt File-Logging — `journalctl -t moongreet`, Debug-Level per `MOONGREET_DEBUG` Env-Var
- **File Permissions**: Cache-Dateien 0o600
- **Testbare Persistence**: `save_*_to`/`load_*_from` Varianten mit konfigurierbarem Pfad für Unit-Tests
- **Shared Wallpaper Texture**: `gdk::Texture` wird einmal in `load_background_texture()` dekodiert und per Ref-Count an alle Fenster geteilt — vermeidet redundante JPEG-Dekodierung pro Monitor
- **Wallpaper-Validierung**: GResource-Zweig via `resources_lookup_data()` + `from_bytes()` (kein Abort bei fehlendem Pfad), Dateigröße-Limit 50 MB, non-UTF-8-Pfade → `None`
- **Error-Detail-Filterung**: GDK/greetd-Fehlerdetails nur auf `debug!`-Level, `warn!` ohne interne Details — verhindert Systeminfo-Leak ins Journal
Generated
+13 -176
View File
@@ -2,65 +2,6 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "aho-corasick"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
dependencies = [
"memchr",
]
[[package]]
name = "anstream"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "anstyle-parse"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys",
]
[[package]]
name = "anyhow"
version = "1.0.102"
@@ -118,35 +59,6 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "colorchoice"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "env_filter"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32e90c2accc4b07a8456ea0debdc2e7587bdd890680d71173a15d4ae604f6eef"
dependencies = [
"log",
"regex",
]
[[package]]
name = "env_logger"
version = "0.11.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a"
dependencies = [
"anstream",
"anstyle",
"env_filter",
"jiff",
"log",
]
[[package]]
name = "equivalent"
version = "1.0.2"
@@ -604,42 +516,12 @@ dependencies = [
"serde_core",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "jiff"
version = "0.2.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359"
dependencies = [
"jiff-static",
"log",
"portable-atomic",
"portable-atomic-util",
"serde_core",
]
[[package]]
name = "jiff-static"
version = "0.2.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "khronos_api"
version = "3.1.0"
@@ -687,19 +569,20 @@ dependencies = [
[[package]]
name = "moongreet"
version = "0.3.0"
version = "0.5.0"
dependencies = [
"env_logger",
"gdk-pixbuf",
"gdk4",
"gio",
"glib",
"glib-build-tools",
"graphene-rs",
"gtk4",
"gtk4-layer-shell",
"log",
"serde",
"serde_json",
"systemd-journal-logger",
"tempfile",
"toml 0.8.23",
]
@@ -710,12 +593,6 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "pango"
version = "0.22.0"
@@ -752,21 +629,6 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]]
name = "portable-atomic"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
[[package]]
name = "portable-atomic-util"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "091397be61a01d4be58e7841595bd4bfedb15f1cd54977d79b8271e94ed799a3"
dependencies = [
"portable-atomic",
]
[[package]]
name = "prettyplease"
version = "0.2.37"
@@ -810,35 +672,6 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "regex"
version = "1.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "rustc_version"
version = "0.4.1"
@@ -964,6 +797,16 @@ dependencies = [
"version-compare",
]
[[package]]
name = "systemd-journal-logger"
version = "2.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7266304d24ca5a4b230545fc558c80e18bd3e1d2eb1be149b6bcd04398d3e79c"
dependencies = [
"log",
"rustix",
]
[[package]]
name = "target-lexicon"
version = "0.13.3"
@@ -1096,12 +939,6 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "version-compare"
version = "0.2.1"
+3 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "moongreet"
version = "0.3.1"
version = "0.5.1"
edition = "2024"
description = "A greetd greeter for Wayland with GTK4 and Layer Shell"
license = "MIT"
@@ -15,8 +15,9 @@ gio = "0.22"
toml = "0.8"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
graphene-rs = { version = "0.22", package = "graphene-rs" }
log = "0.4"
env_logger = "0.11"
systemd-journal-logger = "2.2"
[dev-dependencies]
tempfile = "3"
+21
View File
@@ -0,0 +1,21 @@
# Decisions
## 2026-03-28 GPU blur via GskBlurNode replaces CPU blur
- **Who**: Ragnar, Dom
- **Why**: CPU-side Gaussian blur (`image` crate) blocked the GTK main thread for 500ms2s on 4K wallpapers at cold cache. Disk cache and async orchestration added significant complexity.
- **Tradeoffs**: GPU blur quality is slightly different (box-blur approximation vs true Gaussian), acceptable for wallpaper backgrounds. Removes `image` crate dependency entirely (~15 transitive crates eliminated). No disk cache needed.
- **How**: `Snapshot::push_blur()` + `GskRenderer::render_texture()` on `connect_realize`. Blur happens once on the GPU when the widget gets its renderer, producing a concrete `gdk::Texture`. Zero startup latency. Symmetric with moonlock and moonset.
## 2026-03-28 Optional background blur via `image` crate (superseded)
- **Who**: Selene, Dom
- **Why**: Blurred wallpaper as greeter background is a common UX pattern for login screens
- **Tradeoffs**: Adds `image` crate dependency (~15 transitive crates); CPU-side Gaussian blur at load time adds startup latency proportional to image size and sigma. Acceptable because blur runs once and the texture is shared across monitors.
- **How**: `load_background_texture(bg_path, blur_radius)` loads texture, optionally applies `imageops::blur()`, returns blurred `gdk::Texture`. Config option `background-blur: Option<f32>` in `[appearance]` TOML section.
## 2026-03-28 Audit fixes for shared wallpaper texture (v0.4.1)
- **Who**: Selene, Dominik
- **Why**: Quality, performance, and security audits flagged issues in `load_background_texture()`, debug logging, and greetd error handling
- **Tradeoffs**: GResource path now requires UTF-8 (returns `None` for non-UTF-8 instead of aborting); 50 MB wallpaper limit is generous but prevents OOM; debug logging off by default trades observability for security
- **How**: GResource branch via `resources_lookup_data()` + `from_bytes()` (no abort), file size limit, error details only at debug level, `MOONGREET_DEBUG` env var for log level, greetd retry path truncation matching `show_greetd_error()`
+1 -1
View File
@@ -5,4 +5,4 @@
# Absolute path to wallpaper image
background = "/usr/share/backgrounds/wallpaper.jpg"
# GTK theme for the greeter UI
gtk-theme = "catppuccin-mocha-lavender-standard+default"
gtk-theme = "Colloid-Catppuccin"
+1 -1
View File
@@ -4,7 +4,7 @@
# Maintainer: Dominik Kressler
pkgname=moongreet-git
pkgver=0.3.0.r0.g0000000
pkgver=0.3.1.r5.g4c9b436
pkgrel=1
pkgdesc="A greetd greeter for Wayland with GTK4 and Layer Shell"
arch=('x86_64')
+39 -5
View File
@@ -22,6 +22,8 @@ struct TomlConfig {
#[derive(Debug, Clone, Default, Deserialize)]
struct Appearance {
background: Option<String>,
#[serde(rename = "background-blur")]
background_blur: Option<f32>,
#[serde(rename = "gtk-theme")]
gtk_theme: Option<String>,
}
@@ -30,6 +32,7 @@ struct Appearance {
#[derive(Debug, Clone, Default)]
pub struct Config {
pub background_path: Option<String>,
pub background_blur: Option<f32>,
pub gtk_theme: Option<String>,
}
@@ -40,8 +43,11 @@ pub fn load_config(config_paths: Option<&[PathBuf]>) -> Config {
let mut merged = Config::default();
for path in paths {
if let Ok(content) = fs::read_to_string(path) {
if let Ok(parsed) = toml::from_str::<TomlConfig>(&content) {
match fs::read_to_string(path) {
Ok(content) => {
match toml::from_str::<TomlConfig>(&content) {
Ok(parsed) => {
log::debug!("Config loaded: {}", path.display());
if let Some(appearance) = parsed.appearance {
if let Some(bg) = appearance.background {
// Resolve relative paths against config file directory
@@ -53,14 +59,26 @@ pub fn load_config(config_paths: Option<&[PathBuf]>) -> Config {
Some(parent.join(&bg).to_string_lossy().to_string());
}
}
if appearance.background_blur.is_some() {
merged.background_blur = appearance.background_blur;
}
if appearance.gtk_theme.is_some() {
merged.gtk_theme = appearance.gtk_theme;
}
}
}
Err(e) => {
log::warn!("Config parse error in {}: {e}", path.display());
}
}
}
Err(_) => {
log::debug!("Config not found: {}", path.display());
}
}
}
log::debug!("Config result: background={:?}, blur={:?}, gtk_theme={:?}", merged.background_path, merged.background_blur, merged.gtk_theme);
merged
}
@@ -77,16 +95,20 @@ pub fn resolve_background_path_with(config: &Config, moonarch_wallpaper: &Path)
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 path;
}
log::debug!("Wallpaper: config path {} not found, trying fallbacks", path.display());
}
// Moonarch ecosystem default
if moonarch_wallpaper.is_file() {
log::debug!("Wallpaper: using moonarch default {}", moonarch_wallpaper.display());
return moonarch_wallpaper.to_path_buf();
}
// GResource fallback path (loaded from compiled resources at runtime)
log::debug!("Wallpaper: using GResource fallback");
PathBuf::from(format!("{GRESOURCE_PREFIX}/wallpaper.jpg"))
}
@@ -98,6 +120,7 @@ mod tests {
fn default_config_has_none_fields() {
let config = Config::default();
assert!(config.background_path.is_none());
assert!(config.background_blur.is_none());
assert!(config.gtk_theme.is_none());
}
@@ -115,7 +138,7 @@ mod tests {
let conf = dir.path().join("moongreet.toml");
fs::write(
&conf,
"[appearance]\nbackground = \"/custom/wallpaper.jpg\"\ngtk-theme = \"catppuccin\"\n",
"[appearance]\nbackground = \"/custom/wallpaper.jpg\"\nbackground-blur = 20.0\ngtk-theme = \"catppuccin\"\n",
)
.unwrap();
let paths = vec![conf];
@@ -124,9 +147,20 @@ mod tests {
config.background_path.as_deref(),
Some("/custom/wallpaper.jpg")
);
assert_eq!(config.background_blur, Some(20.0));
assert_eq!(config.gtk_theme.as_deref(), Some("catppuccin"));
}
#[test]
fn load_config_blur_optional() {
let dir = tempfile::tempdir().unwrap();
let conf = dir.path().join("moongreet.toml");
fs::write(&conf, "[appearance]\nbackground = \"/bg.jpg\"\n").unwrap();
let paths = vec![conf];
let config = load_config(Some(&paths));
assert!(config.background_blur.is_none());
}
#[test]
fn load_config_resolves_relative_background() {
let dir = tempfile::tempdir().unwrap();
@@ -180,7 +214,7 @@ mod tests {
fs::write(&wallpaper, "fake").unwrap();
let config = Config {
background_path: Some(wallpaper.to_str().unwrap().to_string()),
gtk_theme: None,
..Config::default()
};
assert_eq!(
resolve_background_path_with(&config, Path::new("/nonexistent")),
@@ -192,7 +226,7 @@ mod tests {
fn resolve_ignores_config_path_when_file_missing() {
let config = Config {
background_path: Some("/nonexistent/wallpaper.jpg".to_string()),
gtk_theme: None,
..Config::default()
};
let result = resolve_background_path_with(&config, Path::new("/nonexistent"));
assert!(result.to_str().unwrap().contains("moongreet"));
+401 -74
View File
@@ -22,6 +22,7 @@ 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;
@@ -91,12 +92,74 @@ fn is_valid_username(name: &str) -> bool {
return false;
}
name.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.' || c == '-')
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.' || c == '-' || c == '@')
}
/// Load background texture from GResource or filesystem.
pub fn load_background_texture(bg_path: &Path) -> Option<gdk::Texture> {
let path_str = bg_path.to_str()?;
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
}
}
}
}
// -- GPU blur via GskBlurNode -------------------------------------------------
/// Render a blurred texture using the GPU via GskBlurNode.
fn render_blurred_texture(
widget: &impl IsA<gtk::Widget>,
texture: &gdk::Texture,
sigma: f32,
) -> Option<gdk::Texture> {
let native = widget.native()?;
let renderer = native.renderer()?;
let snapshot = gtk::Snapshot::new();
let bounds = graphene_rs::Rect::new(
0.0, 0.0, texture.width() as f32, texture.height() as f32,
);
snapshot.push_blur(sigma as f64);
snapshot.append_texture(texture, &bounds);
snapshot.pop();
let node = snapshot.to_node()?;
Some(renderer.render_texture(&node, None))
}
/// Create a wallpaper-only window for secondary monitors.
pub fn create_wallpaper_window(
bg_path: &Path,
texture: &gdk::Texture,
blur_radius: Option<f32>,
app: &gtk::Application,
) -> gtk::ApplicationWindow {
let window = gtk::ApplicationWindow::builder()
@@ -104,22 +167,28 @@ pub fn create_wallpaper_window(
.build();
window.add_css_class("wallpaper");
let background = create_background_picture(bg_path);
let background = create_background_picture(texture, blur_radius);
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(""))
};
/// Create a Picture widget for the wallpaper background, optionally with GPU blur.
fn create_background_picture(texture: &gdk::Texture, blur_radius: Option<f32>) -> gtk::Picture {
let background = gtk::Picture::for_paintable(texture);
background.set_content_fit(gtk::ContentFit::Cover);
background.set_hexpand(true);
background.set_vexpand(true);
if let Some(sigma) = blur_radius.filter(|s| *s > 0.0) {
let texture = texture.clone();
background.connect_realize(move |picture| {
if let Some(blurred) = render_blurred_texture(picture, &texture, sigma) {
picture.set_paintable(Some(&blurred));
}
});
}
background
}
@@ -135,7 +204,7 @@ struct GreeterState {
/// Create the main greeter window with login UI.
pub fn create_greeter_window(
bg_path: &Path,
texture: Option<&gdk::Texture>,
config: &Config,
app: &gtk::Application,
) -> gtk::ApplicationWindow {
@@ -147,14 +216,26 @@ pub fn create_greeter_window(
// 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,
@@ -170,7 +251,9 @@ pub fn create_greeter_window(
window.set_child(Some(&overlay));
// Background wallpaper
overlay.set_child(Some(&create_background_picture(bg_path)));
if let Some(texture) = texture {
overlay.set_child(Some(&create_background_picture(texture, config.background_blur)));
}
// Main layout: 3 rows (top spacer, center login, bottom bar)
let main_box = gtk::Box::new(gtk::Orientation::Vertical, 0);
@@ -317,7 +400,7 @@ pub fn create_greeter_window(
error_label,
move |btn| {
btn.set_sensitive(false);
execute_power_action(power::reboot, strings.reboot_failed, &error_label);
execute_power_action(power::reboot, strings.reboot_failed, &error_label, btn);
}
));
power_box.append(&reboot_btn);
@@ -331,7 +414,7 @@ pub fn create_greeter_window(
error_label,
move |btn| {
btn.set_sensitive(false);
execute_power_action(power::shutdown, strings.shutdown_failed, &error_label);
execute_power_action(power::shutdown, strings.shutdown_failed, &error_label, btn);
}
));
power_box.append(&shutdown_btn);
@@ -481,6 +564,7 @@ fn select_initial_user(
.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,
@@ -507,6 +591,7 @@ fn switch_to_user(
sessions: &[Session],
window: &gtk::ApplicationWindow,
) {
log::debug!("Switching to user: {}", user.username);
{
let mut s = state.borrow_mut();
s.selected_user = Some(user.clone());
@@ -523,8 +608,10 @@ fn switch_to_user(
};
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
@@ -547,15 +634,33 @@ fn set_avatar_from_file(
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 {
// Re-check symlink status to narrow TOCTOU window from get_avatar_path_with()
match std::fs::symlink_metadata(path) {
Ok(meta) if meta.file_type().is_symlink() => {
log::warn!("Rejecting symlink avatar at load time: {}", path.display());
image.set_icon_name(Some("avatar-default-symbolic"));
return;
}
Ok(meta) 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;
}
Err(e) => {
log::debug!("Cannot stat avatar {}: {e}", path.display());
image.set_icon_name(Some("avatar-default-symbolic"));
return;
}
Ok(_) => {}
}
match Pixbuf::from_file_at_scale(path.to_str().unwrap_or(""), AVATAR_SIZE, AVATAR_SIZE, true) {
let Some(path_str) = path.to_str() else {
log::debug!("Non-UTF-8 avatar path, skipping: {}", path.display());
image.set_icon_name(Some("avatar-default-symbolic"));
return;
};
match Pixbuf::from_file_at_scale(path_str, AVATAR_SIZE, AVATAR_SIZE, true) {
Ok(pixbuf) => {
let texture = gdk::Texture::for_pixbuf(&pixbuf);
if let Some(name) = username {
@@ -566,7 +671,8 @@ fn set_avatar_from_file(
}
image.set_paintable(Some(&texture));
}
Err(_) => {
Err(e) => {
log::debug!("Failed to load avatar {}: {e}", path.display());
image.set_icon_name(Some("avatar-default-symbolic"));
}
}
@@ -582,10 +688,12 @@ fn set_default_avatar(
{
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) =
@@ -606,7 +714,9 @@ fn set_default_avatar(
if let Ok(loader) = gdk_pixbuf::PixbufLoader::with_type("svg") {
loader.set_size(AVATAR_SIZE, AVATAR_SIZE);
if loader.write(svg_bytes).is_ok() {
let _ = loader.close();
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());
@@ -629,8 +739,11 @@ fn get_selected_session(
if sessions.is_empty() {
return None;
}
let idx = dropdown.selected() as usize;
sessions.get(idx).cloned()
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.
@@ -665,6 +778,15 @@ fn show_error(
password_entry.grab_focus();
}
/// Extract and length-check a greetd error description from a JSON response.
fn extract_greetd_description<'a>(response: &'a serde_json::Value, fallback: &'a str) -> &'a str {
response
.get("description")
.and_then(|v| v.as_str())
.filter(|d| !d.is_empty() && d.len() <= MAX_GREETD_ERROR_LENGTH)
.unwrap_or(fallback)
}
/// Display a greetd error, using a fallback for missing or oversized descriptions.
fn show_greetd_error(
error_label: &gtk::Label,
@@ -672,19 +794,13 @@ fn show_greetd_error(
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);
}
let message = extract_greetd_description(response, fallback);
show_error(error_label, password_entry, message);
}
/// 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);
@@ -718,6 +834,7 @@ fn attempt_login(
password_entry: &gtk::PasswordEntry,
session_dropdown: &gtk::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,
_ => {
@@ -727,6 +844,7 @@ fn attempt_login(
};
// Validate socket path
log::debug!("GREETD_SOCK: {sock_path}");
let sock_pathbuf = PathBuf::from(&sock_path);
if !sock_pathbuf.is_absolute() {
show_error(
@@ -795,6 +913,7 @@ fn attempt_login(
&sock_path,
&greetd_sock,
&login_cancelled,
strings,
)
})
.await;
@@ -869,20 +988,28 @@ fn login_worker(
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())?;
sock.set_read_timeout(Some(std::time::Duration::from_secs(10)))
.ok();
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) {
@@ -890,24 +1017,21 @@ fn login_worker(
}
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") {
return Ok(LoginResult::Error {
message: response
.get("description")
.and_then(|v| v.as_str())
.unwrap_or("Authentication failed")
.to_string(),
});
let message = extract_greetd_description(&response, 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())?;
@@ -927,29 +1051,31 @@ fn login_worker(
// Multi-stage auth is not supported
let _ = ipc::cancel_session(&mut sock);
return Ok(LoginResult::Error {
message: "Multi-stage authentication is not supported".to_string(),
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: "Invalid session command".to_string(),
message: strings.invalid_session_command.to_string(),
});
}
};
// Validate: first token must be an absolute path to an existing file
let binary = std::path::Path::new(&cmd[0]);
if !binary.is_absolute() || !binary.is_file() {
// 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: "Invalid session command".to_string(),
message: strings.invalid_session_command.to_string(),
});
}
@@ -961,22 +1087,20 @@ fn login_worker(
}
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("Failed to start session")
message: extract_greetd_description(&response, strings.session_start_failed)
.to_string(),
});
}
}
Ok(LoginResult::Error {
message: "Unexpected response from greetd".to_string(),
message: strings.unexpected_greetd_response.to_string(),
})
}
@@ -985,10 +1109,13 @@ fn execute_power_action(
action_fn: fn() -> Result<(), PowerError>,
error_message: &'static str,
error_label: &gtk::Label,
button: &gtk::Button,
) {
glib::spawn_future_local(clone!(
#[weak]
error_label,
#[weak]
button,
async move {
let result = gio::spawn_blocking(move || action_fn()).await;
@@ -998,11 +1125,13 @@ fn execute_power_action(
log::error!("Power action failed: {e}");
error_label.set_text(error_message);
error_label.set_visible(true);
button.set_sensitive(true);
}
Err(_) => {
log::error!("Power action panicked");
error_label.set_text(error_message);
error_label.set_visible(true);
button.set_sensitive(true);
}
}
}
@@ -1012,30 +1141,59 @@ fn execute_power_action(
// -- Last user/session persistence --
fn load_last_user() -> Option<String> {
let content = std::fs::read_to_string(LAST_USER_PATH).ok()?;
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) {
let path = Path::new(LAST_USER_PATH);
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
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 Err(e) = std::fs::create_dir_all(parent)
{
log::warn!("Failed to create cache dir {}: {e}", parent.display());
return;
}
use std::os::unix::fs::OpenOptionsExt;
use std::io::Write;
if let Err(e) = std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.mode(0o600)
.open(path)
.and_then(|mut f| f.write_all(username.as_bytes()))
{
log::warn!("Failed to save last user to {}: {e}", path.display());
}
let _ = std::fs::write(path, username);
}
fn load_last_session(username: &str) -> Option<String> {
let path = Path::new(LAST_SESSION_DIR).join(username);
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
}
}
@@ -1059,7 +1217,23 @@ fn save_last_session(username: &str, session_name: &str) {
}
let dir = Path::new(LAST_SESSION_DIR);
let _ = std::fs::create_dir_all(dir);
let _ = std::fs::write(dir.join(username), session_name);
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;
if let Err(e) = 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()))
{
log::warn!("Failed to save last session to {}: {e}", path.display());
}
}
#[cfg(test)]
@@ -1073,6 +1247,8 @@ mod tests {
assert!(is_valid_username("test-user"));
assert!(is_valid_username("test.user"));
assert!(is_valid_username("_admin"));
assert!(is_valid_username("user@domain"));
assert!(is_valid_username(&"a".repeat(MAX_USERNAME_LENGTH)));
}
#[test]
@@ -1080,6 +1256,7 @@ mod tests {
assert!(!is_valid_username(""));
assert!(!is_valid_username(".hidden"));
assert!(!is_valid_username("-dash"));
assert!(!is_valid_username("@domain"));
assert!(!is_valid_username("user/name"));
assert!(!is_valid_username(&"a".repeat(MAX_USERNAME_LENGTH + 1)));
}
@@ -1103,26 +1280,71 @@ mod tests {
}
#[test]
fn last_user_persistence() {
fn last_user_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("last-user");
std::fs::write(&path, "alice").unwrap();
let content = std::fs::read_to_string(&path).unwrap();
let username = content.trim();
assert!(is_valid_username(username));
assert_eq!(username, "alice");
save_last_user_to(&path, "alice");
let loaded = load_last_user_from(&path);
assert_eq!(loaded, Some("alice".to_string()));
}
#[test]
fn last_session_persistence() {
fn last_user_rejects_invalid() {
let dir = tempfile::tempdir().unwrap();
let session_dir = dir.path().join("last-session");
std::fs::create_dir_all(&session_dir).unwrap();
std::fs::write(session_dir.join("alice"), "Niri").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);
}
let content = std::fs::read_to_string(session_dir.join("alice")).unwrap();
assert_eq!(content.trim(), "Niri");
#[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 --
@@ -1259,6 +1481,7 @@ mod tests {
let result = login_worker(
"alice", "wrongpass", "/usr/bin/niri",
&sock_path, &default_greetd_sock(), &default_cancelled(),
load_strings(Some("en")),
);
let result = result.unwrap();
@@ -1300,6 +1523,7 @@ mod tests {
let result = login_worker(
"alice", "correct", "/usr/bin/bash",
&sock_path, &default_greetd_sock(), &default_cancelled(),
load_strings(Some("en")),
);
let result = result.unwrap();
@@ -1334,6 +1558,7 @@ mod tests {
let result = login_worker(
"alice", "pass", "/usr/bin/niri",
&sock_path, &default_greetd_sock(), &default_cancelled(),
load_strings(Some("en")),
);
let result = result.unwrap();
@@ -1370,6 +1595,7 @@ mod tests {
let result = login_worker(
"alice", "pass", "/usr/bin/bash",
&sock_path, &default_greetd_sock(), &default_cancelled(),
load_strings(Some("en")),
);
let result = result.unwrap();
@@ -1384,12 +1610,25 @@ mod tests {
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_connect_failure() {
let cancelled = Arc::new(std::sync::atomic::AtomicBool::new(false));
let result = login_worker(
"alice", "pass", "/usr/bin/niri",
"/nonexistent/sock", &default_greetd_sock(), &cancelled,
load_strings(Some("en")),
);
assert!(result.is_err());
}
#[test]
fn login_worker_invalid_exec_cmd() {
let (sock_path, handle) = fake_greetd(|stream| {
@@ -1405,19 +1644,107 @@ mod tests {
let _msg = ipc::recv_message(stream).unwrap();
ipc::send_message(stream, &serde_json::json!({"type": "success"})).unwrap();
// cancel_session (from invalid exec_cmd path)
// 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();
});
// Non-absolute exec_cmd
// exec_cmd with path traversal
let result = login_worker(
"alice", "pass", "relative-binary",
"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"));
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);
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);
assert!(result.is_none());
}
#[test]
fn extract_greetd_description_normal() {
let resp = serde_json::json!({"type": "error", "description": "bad password"});
assert_eq!(extract_greetd_description(&resp, "fallback"), "bad password");
}
#[test]
fn extract_greetd_description_oversized() {
let long = "x".repeat(MAX_GREETD_ERROR_LENGTH + 1);
let resp = serde_json::json!({"type": "error", "description": long});
assert_eq!(extract_greetd_description(&resp, "fallback"), "fallback");
}
#[test]
fn extract_greetd_description_empty() {
let resp = serde_json::json!({"type": "error", "description": ""});
assert_eq!(extract_greetd_description(&resp, "fallback"), "fallback");
}
#[test]
fn extract_greetd_description_missing() {
let resp = serde_json::json!({"type": "error"});
assert_eq!(extract_greetd_description(&resp, "fallback"), "fallback");
}
}
+12 -6
View File
@@ -28,8 +28,8 @@ pub struct Strings {
pub session_start_failed: &'static str,
pub reboot_failed: &'static str,
pub shutdown_failed: &'static str,
pub connection_error: &'static str,
pub socket_error: &'static str,
pub unexpected_greetd_response: &'static str,
// Templates (use .replace("{n}", &count.to_string()))
pub faillock_attempts_remaining: &'static str,
@@ -52,8 +52,8 @@ const STRINGS_DE: Strings = Strings {
session_start_failed: "Session konnte nicht gestartet werden",
reboot_failed: "Neustart fehlgeschlagen",
shutdown_failed: "Herunterfahren fehlgeschlagen",
connection_error: "Verbindungsfehler",
socket_error: "Socket-Fehler",
unexpected_greetd_response: "Unerwartete Antwort von greetd",
faillock_attempts_remaining: "Noch {n} Versuch(e) vor Kontosperrung!",
faillock_locked: "Konto ist möglicherweise gesperrt",
};
@@ -74,8 +74,8 @@ const STRINGS_EN: Strings = Strings {
session_start_failed: "Failed to start session",
reboot_failed: "Reboot failed",
shutdown_failed: "Shutdown failed",
connection_error: "Connection error",
socket_error: "Socket error",
unexpected_greetd_response: "Unexpected response from greetd",
faillock_attempts_remaining: "{n} attempt(s) remaining before lockout!",
faillock_locked: "Account may be locked",
};
@@ -124,10 +124,15 @@ pub fn detect_locale() -> String {
.filter(|s| !s.is_empty())
.or_else(|| read_lang_from_conf(Path::new(DEFAULT_LOCALE_CONF)));
match lang {
Some(l) => parse_lang_prefix(&l),
let result = match lang {
Some(ref l) => parse_lang_prefix(l),
None => "en".to_string(),
}
};
log::debug!("Detected locale: {result} (source: {})", match lang {
Some(_) => "LANG env or locale.conf",
None => "default",
});
result
}
/// Return the string table for the given locale, defaulting to English.
@@ -281,6 +286,7 @@ mod tests {
assert!(!s.shutdown_failed.is_empty(), "{locale}: shutdown_failed");
assert!(!s.faillock_attempts_remaining.is_empty(), "{locale}: faillock_attempts_remaining");
assert!(!s.faillock_locked.is_empty(), "{locale}: faillock_locked");
assert!(!s.unexpected_greetd_response.is_empty(), "{locale}: unexpected_greetd_response");
}
}
+7 -5
View File
@@ -75,7 +75,6 @@ fn recv_payload(stream: &mut UnixStream, n: usize) -> Result<Vec<u8>, IpcError>
}
/// Send a length-prefixed JSON message to the greetd socket.
/// Header and payload are sent in a single write for atomicity.
pub fn send_message(
stream: &mut UnixStream,
msg: &serde_json::Value,
@@ -85,11 +84,12 @@ pub fn send_message(
return Err(IpcError::PayloadTooLarge(payload.len()));
}
let msg_type = msg.get("type").and_then(|v| v.as_str()).unwrap_or("unknown");
log::debug!("IPC send: type={msg_type}, size={} bytes", payload.len());
let header = (payload.len() as u32).to_le_bytes();
let mut buf = Vec::with_capacity(4 + payload.len());
buf.extend_from_slice(&header);
buf.extend_from_slice(&payload);
stream.write_all(&buf)?;
stream.write_all(&header)?;
stream.write_all(&payload)?;
Ok(())
}
@@ -104,6 +104,8 @@ pub fn recv_message(stream: &mut UnixStream) -> Result<serde_json::Value, IpcErr
let payload = recv_payload(stream, length)?;
let value: serde_json::Value = serde_json::from_slice(&payload)?;
let msg_type = value.get("type").and_then(|v| v.as_str()).unwrap_or("unknown");
log::debug!("IPC recv: type={msg_type}, size={length} bytes");
Ok(value)
}
+34 -24
View File
@@ -13,8 +13,6 @@ use gdk4 as gdk;
use gtk4::prelude::*;
use gtk4::{self as gtk, gio};
use gtk4_layer_shell::LayerShell;
use std::path::PathBuf;
fn load_css(display: &gdk::Display) {
let css_provider = gtk::CssProvider::new();
css_provider.load_from_resource("/dev/moonarch/moongreet/style.css");
@@ -25,9 +23,9 @@ fn load_css(display: &gdk::Display) {
);
}
fn setup_layer_shell(window: &gtk::ApplicationWindow, keyboard: bool) {
fn setup_layer_shell(window: &gtk::ApplicationWindow, keyboard: bool, layer: gtk4_layer_shell::Layer) {
window.init_layer_shell();
window.set_layer(gtk4_layer_shell::Layer::Top);
window.set_layer(layer);
window.set_exclusive_zone(-1);
if keyboard {
window.set_keyboard_mode(gtk4_layer_shell::KeyboardMode::Exclusive);
@@ -48,31 +46,44 @@ fn activate(app: &gtk::Application) {
}
};
log::debug!("Display: {:?}", display);
load_css(&display);
// Load config and resolve wallpaper
let config = config::load_config(None);
let bg_path = config::resolve_background_path(&config);
log::debug!("Background path: {}", bg_path.display());
// Load background texture once — shared across all windows
// Blur is applied on the GPU via GskBlurNode at widget realization time.
let bg_texture = greeter::load_background_texture(&bg_path);
if bg_texture.is_none() {
log::error!("Failed to load background texture — greeter will start without wallpaper");
}
let use_layer_shell = std::env::var("MOONGREET_NO_LAYER_SHELL").is_err();
log::debug!("Layer shell: {use_layer_shell}");
// Main greeter window (login UI) — compositor picks focused monitor
let greeter_window = greeter::create_greeter_window(&bg_path, &config, app);
let greeter_window = greeter::create_greeter_window(bg_texture.as_ref(), &config, app);
if use_layer_shell {
setup_layer_shell(&greeter_window, true);
setup_layer_shell(&greeter_window, true, gtk4_layer_shell::Layer::Top);
}
greeter_window.present();
// Wallpaper-only windows on all monitors (only with layer shell)
if use_layer_shell {
if use_layer_shell
&& let Some(ref texture) = bg_texture
{
let monitors = display.monitors();
log::debug!("Monitor count: {}", monitors.n_items());
for i in 0..monitors.n_items() {
if let Some(monitor) = monitors
.item(i)
.and_then(|obj| obj.downcast::<gdk::Monitor>().ok())
{
let wallpaper = greeter::create_wallpaper_window(&bg_path, app);
setup_layer_shell(&wallpaper, false);
let wallpaper = greeter::create_wallpaper_window(texture, config.background_blur, app);
setup_layer_shell(&wallpaper, false, gtk4_layer_shell::Layer::Bottom);
wallpaper.set_monitor(Some(&monitor));
wallpaper.present();
}
@@ -81,23 +92,22 @@ fn activate(app: &gtk::Application) {
}
fn setup_logging() {
let mut builder = env_logger::Builder::from_default_env();
builder.filter_level(log::LevelFilter::Info);
// Try file logging to /var/cache/moongreet/ — fall back to stderr
let log_dir = PathBuf::from("/var/cache/moongreet");
if log_dir.is_dir() {
let log_file = log_dir.join("moongreet.log");
if let Ok(file) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&log_file)
{
builder.target(env_logger::Target::Pipe(Box::new(file)));
match systemd_journal_logger::JournalLog::new() {
Ok(logger) => {
if let Err(e) = logger.install() {
eprintln!("Failed to install journal logger: {e}");
}
}
builder.init();
Err(e) => {
eprintln!("Failed to create journal logger: {e}");
}
}
let level = if std::env::var("MOONGREET_DEBUG").is_ok() {
log::LevelFilter::Debug
} else {
log::LevelFilter::Info
};
log::set_max_level(level);
}
fn main() {
+5 -12
View File
@@ -7,7 +7,6 @@ use std::process::Command;
#[derive(Debug)]
pub enum PowerError {
CommandFailed { action: &'static str, message: String },
Timeout { action: &'static str },
}
impl fmt::Display for PowerError {
@@ -16,9 +15,6 @@ impl fmt::Display for PowerError {
PowerError::CommandFailed { action, message } => {
write!(f, "{action} failed: {message}")
}
PowerError::Timeout { action } => {
write!(f, "{action} timed out")
}
}
}
}
@@ -27,6 +23,7 @@ impl std::error::Error for PowerError {}
/// Run a command and return a PowerError on failure.
fn run_command(action: &'static str, program: &str, args: &[&str]) -> Result<(), PowerError> {
log::debug!("Power action: {action} ({program} {args:?})");
let child = Command::new(program)
.args(args)
.spawn()
@@ -42,7 +39,9 @@ fn run_command(action: &'static str, program: &str, args: &[&str]) -> Result<(),
message: e.to_string(),
})?;
if !output.status.success() {
if output.status.success() {
log::debug!("Power action {action} completed successfully");
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(PowerError::CommandFailed {
action,
@@ -76,12 +75,6 @@ mod tests {
assert_eq!(err.to_string(), "reboot failed: No such file or directory");
}
#[test]
fn power_error_timeout_display() {
let err = PowerError::Timeout { action: "shutdown" };
assert_eq!(err.to_string(), "shutdown timed out");
}
#[test]
fn run_command_returns_error_for_missing_binary() {
let result = run_command("test", "nonexistent-binary-xyz", &[]);
@@ -106,7 +99,7 @@ mod tests {
#[test]
fn run_command_passes_args() {
let result = run_command("test", "echo", &["hello", "world"]);
let result = run_command("test", "true", &["--ignored-arg"]);
assert!(result.is_ok());
}
}
+17 -3
View File
@@ -12,6 +12,7 @@ const DEFAULT_XSESSION_DIRS: &[&str] = &["/usr/share/xsessions"];
pub struct Session {
pub name: String,
pub exec_cmd: String,
#[allow(dead_code)] // Retained for future Wayland-only filtering
pub session_type: String,
}
@@ -46,8 +47,17 @@ fn parse_desktop_file(path: &Path, session_type: &str) -> Option<Session> {
}
}
let name = name.filter(|s| !s.is_empty())?;
let exec_cmd = exec_cmd.filter(|s| !s.is_empty())?;
let name = name.filter(|s| !s.is_empty());
let exec_cmd = exec_cmd.filter(|s| !s.is_empty());
if name.is_none() || exec_cmd.is_none() {
log::debug!("Skipping {}: missing Name={} Exec={}", path.display(),
name.is_some(), exec_cmd.is_some());
return None;
}
let name = name?;
let exec_cmd = exec_cmd?;
Some(Session {
name,
@@ -74,7 +84,10 @@ pub fn get_sessions(
for (dirs, session_type) in [(wayland, "wayland"), (xsession, "x11")] {
for directory in dirs {
let entries = match fs::read_dir(directory) {
Ok(e) => e,
Ok(e) => {
log::debug!("Scanning session directory: {}", directory.display());
e
}
Err(_) => continue,
};
@@ -93,6 +106,7 @@ pub fn get_sessions(
}
}
log::debug!("Found {} session(s)", sessions.len());
sessions
}
+26 -12
View File
@@ -23,9 +23,11 @@ const NOLOGIN_SHELLS: &[&str] = &[
#[derive(Debug, Clone)]
pub struct User {
pub username: String,
#[allow(dead_code)] // Retained for debugging and future UID-based features
pub uid: u32,
pub gecos: String,
pub home: PathBuf,
#[allow(dead_code)] // Retained for debugging and future shell-based filtering
pub shell: String,
}
@@ -46,22 +48,22 @@ pub fn get_users(passwd_path: Option<&Path>) -> Vec<User> {
let content = match fs::read_to_string(path) {
Ok(c) => c,
Err(_) => return Vec::new(),
Err(e) => {
log::warn!("Failed to read passwd file {}: {e}", path.display());
return Vec::new();
}
};
let mut users = Vec::new();
for line in content.lines() {
let parts: Vec<&str> = line.split(':').collect();
if parts.len() < 7 {
let mut fields = line.splitn(7, ':');
let (Some(username), Some(_pw), Some(uid_str), Some(_gid), Some(gecos), Some(home), Some(shell)) =
(fields.next(), fields.next(), fields.next(), fields.next(),
fields.next(), fields.next(), fields.next())
else {
continue;
}
let username = parts[0];
let uid_str = parts[2];
let gecos = parts[4];
let home = parts[5];
let shell = parts[6];
};
let uid = match uid_str.parse::<u32>() {
Ok(u) => u,
@@ -88,6 +90,7 @@ pub fn get_users(passwd_path: Option<&Path>) -> Vec<User> {
});
}
log::debug!("Found {} login user(s)", users.len());
users
}
@@ -106,17 +109,28 @@ pub fn get_avatar_path_with(
// AccountsService icon takes priority
if accountsservice_dir.exists() {
let icon = accountsservice_dir.join(username);
if icon.exists() && !icon.is_symlink() {
if let Ok(meta) = icon.symlink_metadata() {
if meta.file_type().is_symlink() {
log::warn!("Rejecting symlink avatar for {username}: {}", icon.display());
} else {
log::debug!("Avatar for {username}: AccountsService {}", icon.display());
return Some(icon);
}
}
}
// ~/.face fallback
let face = home.join(".face");
if face.exists() && !face.is_symlink() {
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
}