Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f6f33a13ab | |||
| 60d294fa37 | |||
| 1d557ea135 | |||
| a2dc89854d | |||
| f3f4db1ab1 | |||
| a61fa4e145 |
@@ -0,0 +1,43 @@
|
|||||||
|
# ABOUTME: Updates pkgver in moonarch-pkgbuilds after a push to main.
|
||||||
|
# ABOUTME: Ensures paru detects new versions of this package.
|
||||||
|
|
||||||
|
name: Update PKGBUILD version
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
update-pkgver:
|
||||||
|
runs-on: moonarch
|
||||||
|
steps:
|
||||||
|
- name: Checkout source repo
|
||||||
|
run: |
|
||||||
|
git clone --bare http://gitea:3000/nevaforget/greetd-moongreet.git source.git
|
||||||
|
cd source.git
|
||||||
|
PKGVER=$(git describe --long --tags | sed 's/^v//;s/-/.r/;s/-/./')
|
||||||
|
echo "New pkgver: $PKGVER"
|
||||||
|
echo "$PKGVER" > /tmp/pkgver
|
||||||
|
|
||||||
|
- name: Update PKGBUILD
|
||||||
|
run: |
|
||||||
|
PKGVER=$(cat /tmp/pkgver)
|
||||||
|
git clone http://gitea:3000/nevaforget/moonarch-pkgbuilds.git pkgbuilds
|
||||||
|
cd pkgbuilds
|
||||||
|
|
||||||
|
OLD_VER=$(grep '^pkgver=' moongreet-git/PKGBUILD | cut -d= -f2)
|
||||||
|
if [ "$OLD_VER" = "$PKGVER" ]; then
|
||||||
|
echo "pkgver already up to date ($PKGVER)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
sed -i "s/^pkgver=.*/pkgver=$PKGVER/" moongreet-git/PKGBUILD
|
||||||
|
sed -i "s/^\tpkgver = .*/\tpkgver = $PKGVER/" moongreet-git/.SRCINFO
|
||||||
|
echo "Updated pkgver: $OLD_VER → $PKGVER"
|
||||||
|
|
||||||
|
git config user.name "pkgver-bot"
|
||||||
|
git config user.email "gitea@moonarch.de"
|
||||||
|
git add moongreet-git/PKGBUILD moongreet-git/.SRCINFO
|
||||||
|
git commit -m "chore(moongreet-git): bump pkgver to $PKGVER"
|
||||||
|
git -c http.extraHeader="Authorization: token ${{ secrets.PKGBUILD_TOKEN }}" push
|
||||||
@@ -45,8 +45,8 @@ cd pkg && makepkg -sf && sudo pacman -U moongreet-git-<version>-x86_64.pkg.tar.z
|
|||||||
- `power.rs` — Reboot/Shutdown via loginctl
|
- `power.rs` — Reboot/Shutdown via loginctl
|
||||||
- `i18n.rs` — Locale-Erkennung (LANG / /etc/locale.conf) und String-Tabellen (DE/EN), alle UI- und Login-Fehlermeldungen
|
- `i18n.rs` — Locale-Erkennung (LANG / /etc/locale.conf) und String-Tabellen (DE/EN), alle UI- und Login-Fehlermeldungen
|
||||||
- `fingerprint.rs` — fprintd D-Bus Probe (gio::DBusProxy) — Geräteerkennung und Enrollment-Check für UI-Feedback
|
- `fingerprint.rs` — fprintd D-Bus Probe (gio::DBusProxy) — Geräteerkennung und Enrollment-Check für UI-Feedback
|
||||||
- `config.rs` — TOML-Config ([appearance] background, gtk-theme, fingerprint-enabled) + Wallpaper-Fallback
|
- `config.rs` — TOML-Config ([appearance] background, gtk-theme, fingerprint-enabled) + Wallpaper-Fallback + Blur-Validierung (finite, clamp 0–200)
|
||||||
- `greeter.rs` — GTK4 UI (Overlay-Layout), Login-Flow via greetd IPC (Multi-Stage-Auth für fprintd), Faillock-Warnung, Avatar-Cache, Last-User/Last-Session Persistence (0o600 Permissions)
|
- `greeter.rs` — GTK4 UI (Overlay-Layout), Login-Flow via greetd IPC (Multi-Stage-Auth für fprintd), Faillock-Warnung, Avatar-Cache, Last-User/Last-Session Persistence (0o700 Dirs, 0o600 Files)
|
||||||
- `main.rs` — Entry Point, GTK App, Layer Shell Setup, Multi-Monitor, systemd-journal-logger
|
- `main.rs` — Entry Point, GTK App, Layer Shell Setup, Multi-Monitor, systemd-journal-logger
|
||||||
- `resources/style.css` — Catppuccin-inspiriertes Theme
|
- `resources/style.css` — Catppuccin-inspiriertes Theme
|
||||||
|
|
||||||
@@ -57,13 +57,13 @@ cd pkg && makepkg -sf && sudo pacman -U moongreet-git-<version>-x86_64.pkg.tar.z
|
|||||||
- **Async Login**: `glib::spawn_future_local` + `gio::spawn_blocking` statt raw Threads
|
- **Async Login**: `glib::spawn_future_local` + `gio::spawn_blocking` statt raw Threads
|
||||||
- **Socket-Cancellation**: `Arc<Mutex<Option<UnixStream>>>` + `AtomicBool` für saubere Abbrüche
|
- **Socket-Cancellation**: `Arc<Mutex<Option<UnixStream>>>` + `AtomicBool` für saubere Abbrüche
|
||||||
- **Avatar-Cache**: `HashMap<String, gdk::Texture>` in `Rc<RefCell<GreeterState>>`
|
- **Avatar-Cache**: `HashMap<String, gdk::Texture>` in `Rc<RefCell<GreeterState>>`
|
||||||
- **GPU-Blur via GskBlurNode**: `Snapshot::push_blur()` + `GskRenderer::render_texture()` im `connect_realize` Callback — kein CPU-Blur, kein Disk-Cache, kein `image`-Crate
|
- **GPU-Blur via GskBlurNode**: `Snapshot::push_blur()` + `GskRenderer::render_texture()` im `connect_realize` Callback — kein CPU-Blur, kein Disk-Cache, kein `image`-Crate. Blurred Texture wird per `Rc<RefCell<Option<gdk::Texture>>>` über alle Monitore gecacht (1x GPU-Renderpass statt N).
|
||||||
- **Fingerprint via greetd Multi-Stage PAM**: fprintd D-Bus nur als Probe (Gerät/Enrollment), eigentliche Verifizierung läuft über PAM im greetd-Auth-Loop. `auth_message_type: "secret"` → Passwort, alles andere → `None` (PAM entscheidet). 60s Socket-Timeout bei fprintd.
|
- **Fingerprint via greetd Multi-Stage PAM**: fprintd D-Bus nur als Probe (Gerät/Enrollment), eigentliche Verifizierung läuft über PAM im greetd-Auth-Loop. `auth_message_type: "secret"` → Passwort, alles andere → `None` (PAM entscheidet). 60s Socket-Timeout bei fprintd. Device-Proxy in `GreeterState` gecacht, Generation-Counter gegen Race Conditions bei schnellem User-Switch.
|
||||||
- **Symmetrie mit moonlock/moonset**: Gleiche Patterns (i18n, config, users, power, GResource, GPU-Blur)
|
- **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
|
- **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
|
- **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
|
- **Journal-Logging**: `systemd-journal-logger` statt File-Logging — `journalctl -t moongreet`, Debug-Level per `MOONGREET_DEBUG` Env-Var
|
||||||
- **File Permissions**: Cache-Dateien 0o600
|
- **File Permissions**: Cache-Verzeichnisse 0o700 via `DirBuilder::mode()`, Cache-Dateien 0o600
|
||||||
- **Testbare Persistence**: `save_*_to`/`load_*_from` Varianten mit konfigurierbarem Pfad für Unit-Tests
|
- **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
|
- **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`
|
- **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`
|
||||||
|
|||||||
Generated
+27
-1
@@ -59,6 +59,12 @@ version = "1.0.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg_aliases"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "equivalent"
|
name = "equivalent"
|
||||||
version = "1.0.2"
|
version = "1.0.2"
|
||||||
@@ -569,7 +575,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "moongreet"
|
name = "moongreet"
|
||||||
version = "0.6.1"
|
version = "0.7.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"gdk-pixbuf",
|
"gdk-pixbuf",
|
||||||
"gdk4",
|
"gdk4",
|
||||||
@@ -580,11 +586,25 @@ dependencies = [
|
|||||||
"gtk4",
|
"gtk4",
|
||||||
"gtk4-layer-shell",
|
"gtk4-layer-shell",
|
||||||
"log",
|
"log",
|
||||||
|
"nix",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"systemd-journal-logger",
|
"systemd-journal-logger",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"toml 0.8.23",
|
"toml 0.8.23",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nix"
|
||||||
|
version = "0.29.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"cfg-if",
|
||||||
|
"cfg_aliases",
|
||||||
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1124,6 +1144,12 @@ version = "0.8.28"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f"
|
checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zeroize"
|
||||||
|
version = "1.8.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zmij"
|
name = "zmij"
|
||||||
version = "1.0.21"
|
version = "1.0.21"
|
||||||
|
|||||||
+8
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "moongreet"
|
name = "moongreet"
|
||||||
version = "0.6.1"
|
version = "0.7.1"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "A greetd greeter for Wayland with GTK4 and Layer Shell"
|
description = "A greetd greeter for Wayland with GTK4 and Layer Shell"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
@@ -16,11 +16,18 @@ toml = "0.8"
|
|||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
graphene-rs = { version = "0.22", package = "graphene-rs" }
|
graphene-rs = { version = "0.22", package = "graphene-rs" }
|
||||||
|
nix = { version = "0.29", features = ["signal"] }
|
||||||
|
zeroize = { version = "1", features = ["std"] }
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
systemd-journal-logger = "2.2"
|
systemd-journal-logger = "2.2"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
lto = "thin"
|
||||||
|
codegen-units = 1
|
||||||
|
strip = true
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
glib-build-tools = "0.22"
|
glib-build-tools = "0.22"
|
||||||
|
|||||||
@@ -1,5 +1,19 @@
|
|||||||
# Decisions
|
# Decisions
|
||||||
|
|
||||||
|
## 2026-03-31 – Fourth audit: power timeout, timing mitigation, release profile, GREETD_SOCK caching
|
||||||
|
|
||||||
|
- **Who**: Ragnar, Dom
|
||||||
|
- **Why**: Fourth triple audit found moongreet power.rs had no timeout on loginctl (greeter could freeze), username enumeration via timing differential, GREETD_SOCK re-read on every login, missing release profile, and missing GResource compression.
|
||||||
|
- **Tradeoffs**: 500ms minimum login response time adds slight delay on fast auth but prevents timing-based username enumeration. Power timeout (30s + SIGKILL) matches moonset pattern — aggressive but prevents greeter freeze.
|
||||||
|
- **How**: (1) power.rs adapted from moonset with 30s timeout + SIGKILL (nix dependency added). (2) 500ms min response floor in attempt_login via Instant + glib::timeout_future. (3) GREETD_SOCK cached in GreeterState at startup. (4) `[profile.release]` with LTO, codegen-units=1, strip. (5) `compressed="true"` on GResource entries. (6) SYNC comments on duplicated blur/background functions.
|
||||||
|
|
||||||
|
## 2026-03-30 – Full audit fix: security, quality, performance (v0.6.2)
|
||||||
|
|
||||||
|
- **Who**: Ragnar, Dom
|
||||||
|
- **Why**: Three parallel audits (security, code quality, performance) identified 10 actionable findings across the codebase — from world-readable cache dirs to a GPU blur geometry bug to a race condition in fingerprint probing.
|
||||||
|
- **Tradeoffs**: `too_many_arguments` Clippy warnings suppressed with `#[allow]` rather than introducing a `UiWidgets` struct — GTK's `clone!` macro with `#[weak]` refs requires individual widget parameters, a struct would fight the idiom. Async avatar loading skipped because `Pixbuf` is `!Send`; cache already prevents repeat loads. TOCTOU socket pre-check removed entirely — `connect()` in login_worker already handles errors, the `metadata()` check gave false security guarantees.
|
||||||
|
- **How**: Cache dirs use `DirBuilder::mode(0o700)` instead of `create_dir_all`. Blur config clamped to `0.0..=200.0` with `is_finite()` guard. Blur texture cached in `Rc<RefCell<Option<gdk::Texture>>>` across monitors. FingerprintProbe device proxy cached in `GreeterState` with generation counter to prevent stale async writes. GPU blur geometry fixed (`-pad` origin shift instead of texture stretching). `is_valid_gtk_theme` extracted as testable function. 9 new tests.
|
||||||
|
|
||||||
## 2026-03-29 – Fingerprint authentication via greetd multi-stage PAM
|
## 2026-03-29 – Fingerprint authentication via greetd multi-stage PAM
|
||||||
|
|
||||||
- **Who**: Ragnar, Dom
|
- **Who**: Ragnar, Dom
|
||||||
|
|||||||
@@ -13,9 +13,12 @@ Part of the Moonarch ecosystem.
|
|||||||
- **Power actions** — Reboot / Shutdown via `loginctl`
|
- **Power actions** — Reboot / Shutdown via `loginctl`
|
||||||
- **Layer Shell** — Fullscreen via gtk4-layer-shell (TOP layer)
|
- **Layer Shell** — Fullscreen via gtk4-layer-shell (TOP layer)
|
||||||
- **Multi-monitor** — Greeter on primary, wallpaper on all monitors
|
- **Multi-monitor** — Greeter on primary, wallpaper on all monitors
|
||||||
|
- **GPU blur** — Background blur via GskBlurNode (shared cache across monitors)
|
||||||
- **i18n** — German and English (auto-detected from system locale)
|
- **i18n** — German and English (auto-detected from system locale)
|
||||||
- **Faillock warning** — Warns after 2 failed attempts, locked message after 3
|
- **Faillock warning** — Warns after 2 failed attempts, locked message after 3
|
||||||
- **Fingerprint** — fprintd support via greetd multi-stage PAM (configurable)
|
- **Fingerprint** — fprintd support via greetd multi-stage PAM (configurable)
|
||||||
|
- **Journal logging** — `journalctl -t moongreet`, debug level via `MOONGREET_DEBUG` env var
|
||||||
|
- **Password wiping** — Zeroize on drop
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
@@ -66,8 +69,8 @@ cargo test
|
|||||||
# Build release
|
# Build release
|
||||||
cargo build --release
|
cargo build --release
|
||||||
|
|
||||||
# Run locally (without greetd, needs LD_PRELOAD for layer-shell)
|
# Run locally (without greetd, disables layer-shell)
|
||||||
LD_PRELOAD=/usr/lib/libgtk4-layer-shell.so ./target/release/moongreet
|
MOONGREET_NO_LAYER_SHELL=1 ./target/release/moongreet
|
||||||
```
|
```
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// ABOUTME: Build script for compiling GResource bundle.
|
// ABOUTME: Build script for compiling GResource bundle.
|
||||||
// ABOUTME: Bundles style.css, wallpaper.jpg, and default-avatar.svg into the binary.
|
// ABOUTME: Bundles style.css and default-avatar.svg into the binary.
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
glib_build_tools::compile_resources(
|
glib_build_tools::compile_resources(
|
||||||
|
|||||||
@@ -5,4 +5,4 @@
|
|||||||
# Absolute path to wallpaper image
|
# Absolute path to wallpaper image
|
||||||
background = "/usr/share/backgrounds/wallpaper.jpg"
|
background = "/usr/share/backgrounds/wallpaper.jpg"
|
||||||
# GTK theme for the greeter UI
|
# GTK theme for the greeter UI
|
||||||
gtk-theme = "Colloid-Catppuccin"
|
gtk-theme = "Colloid-Grey-Dark-Catppuccin"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<gresources>
|
<gresources>
|
||||||
<gresource prefix="/dev/moonarch/moongreet">
|
<gresource prefix="/dev/moonarch/moongreet">
|
||||||
<file>style.css</file>
|
<file compressed="true">style.css</file>
|
||||||
<file>default-avatar.svg</file>
|
<file compressed="true">default-avatar.svg</file>
|
||||||
</gresource>
|
</gresource>
|
||||||
</gresources>
|
</gresources>
|
||||||
|
|||||||
+54
-7
@@ -72,8 +72,12 @@ pub fn load_config(config_paths: Option<&[PathBuf]>) -> Config {
|
|||||||
Some(parent.join(&bg).to_string_lossy().to_string());
|
Some(parent.join(&bg).to_string_lossy().to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if appearance.background_blur.is_some() {
|
if let Some(blur) = appearance.background_blur {
|
||||||
merged.background_blur = appearance.background_blur;
|
if blur.is_finite() {
|
||||||
|
merged.background_blur = Some(blur.clamp(0.0, 200.0));
|
||||||
|
} else {
|
||||||
|
log::warn!("Ignoring non-finite background-blur value");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if appearance.gtk_theme.is_some() {
|
if appearance.gtk_theme.is_some() {
|
||||||
merged.gtk_theme = appearance.gtk_theme;
|
merged.gtk_theme = appearance.gtk_theme;
|
||||||
@@ -107,14 +111,16 @@ pub fn resolve_background_path(config: &Config) -> Option<PathBuf> {
|
|||||||
|
|
||||||
/// Resolve with configurable moonarch wallpaper path (for testing).
|
/// Resolve with configurable moonarch wallpaper path (for testing).
|
||||||
pub fn resolve_background_path_with(config: &Config, moonarch_wallpaper: &Path) -> Option<PathBuf> {
|
pub fn resolve_background_path_with(config: &Config, moonarch_wallpaper: &Path) -> Option<PathBuf> {
|
||||||
// User-configured path
|
// User-configured path — reject symlinks to prevent path traversal
|
||||||
if let Some(ref bg) = config.background_path {
|
if let Some(ref bg) = config.background_path {
|
||||||
let path = PathBuf::from(bg);
|
let path = PathBuf::from(bg);
|
||||||
if path.is_file() {
|
if let Ok(meta) = path.symlink_metadata() {
|
||||||
log::debug!("Wallpaper: using config path {}", path.display());
|
if meta.is_file() && !meta.file_type().is_symlink() {
|
||||||
return Some(path);
|
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
|
// Moonarch ecosystem default
|
||||||
@@ -283,4 +289,45 @@ mod tests {
|
|||||||
let config = load_config(Some(&paths));
|
let config = load_config(Some(&paths));
|
||||||
assert!(!config.fingerprint_enabled);
|
assert!(!config.fingerprint_enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -- Blur validation tests --
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_config_blur_clamped_to_max() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let conf = dir.path().join("moongreet.toml");
|
||||||
|
fs::write(&conf, "[appearance]\nbackground-blur = 999.0\n").unwrap();
|
||||||
|
let config = load_config(Some(&[conf]));
|
||||||
|
assert_eq!(config.background_blur, Some(200.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_config_blur_negative_clamped_to_zero() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let conf = dir.path().join("moongreet.toml");
|
||||||
|
fs::write(&conf, "[appearance]\nbackground-blur = -5.0\n").unwrap();
|
||||||
|
let config = load_config(Some(&[conf]));
|
||||||
|
assert_eq!(config.background_blur, Some(0.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_config_blur_nan_rejected() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let conf = dir.path().join("moongreet.toml");
|
||||||
|
// TOML doesn't support NaN literals, but the parser may return NaN for nan
|
||||||
|
fs::write(&conf, "[appearance]\nbackground-blur = nan\n").unwrap();
|
||||||
|
let config = load_config(Some(&[conf]));
|
||||||
|
// nan is not valid TOML float, so the whole config parse fails → no blur
|
||||||
|
assert!(config.background_blur.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_config_blur_inf_rejected() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let conf = dir.path().join("moongreet.toml");
|
||||||
|
fs::write(&conf, "[appearance]\nbackground-blur = inf\n").unwrap();
|
||||||
|
let config = load_config(Some(&[conf]));
|
||||||
|
// inf is valid TOML → parsed as f32::INFINITY → rejected by is_finite() guard
|
||||||
|
assert!(config.background_blur.is_none());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const FPRINTD_MANAGER_IFACE: &str = "net.reactivated.Fprint.Manager";
|
|||||||
const FPRINTD_DEVICE_IFACE: &str = "net.reactivated.Fprint.Device";
|
const FPRINTD_DEVICE_IFACE: &str = "net.reactivated.Fprint.Device";
|
||||||
|
|
||||||
const DBUS_TIMEOUT_MS: i32 = 3000;
|
const DBUS_TIMEOUT_MS: i32 = 3000;
|
||||||
|
const FPRINTD_DEVICE_PREFIX: &str = "/net/reactivated/Fprint/Device/";
|
||||||
|
|
||||||
/// Lightweight fprintd probe — detects device availability and finger enrollment.
|
/// Lightweight fprintd probe — detects device availability and finger enrollment.
|
||||||
/// Does NOT perform verification (that happens through greetd/PAM).
|
/// Does NOT perform verification (that happens through greetd/PAM).
|
||||||
@@ -66,6 +67,10 @@ impl FingerprintProbe {
|
|||||||
if device_path.is_empty() {
|
if device_path.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if !device_path.starts_with(FPRINTD_DEVICE_PREFIX) {
|
||||||
|
log::warn!("Unexpected fprintd device path: {device_path}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
match gio::DBusProxy::for_bus_future(
|
match gio::DBusProxy::for_bus_future(
|
||||||
gio::BusType::System,
|
gio::BusType::System,
|
||||||
|
|||||||
+212
-70
@@ -12,6 +12,7 @@ use std::os::unix::net::UnixStream;
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
use crate::i18n::{faillock_warning, load_strings, Strings};
|
use crate::i18n::{faillock_warning, load_strings, Strings};
|
||||||
@@ -87,7 +88,9 @@ fn is_valid_username(name: &str) -> bool {
|
|||||||
if name.is_empty() || name.len() > MAX_USERNAME_LENGTH {
|
if name.is_empty() || name.len() > MAX_USERNAME_LENGTH {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
let first = name.chars().next().unwrap();
|
let Some(first) = name.chars().next() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
if !first.is_ascii_alphanumeric() && first != '_' {
|
if !first.is_ascii_alphanumeric() && first != '_' {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -95,16 +98,28 @@ fn is_valid_username(name: &str) -> bool {
|
|||||||
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.' || c == '-' || c == '@')
|
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.' || c == '-' || c == '@')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Validate a GTK theme name — alphanumeric plus `_-+.` only.
|
||||||
|
fn is_valid_gtk_theme(name: &str) -> bool {
|
||||||
|
!name.is_empty()
|
||||||
|
&& name
|
||||||
|
.chars()
|
||||||
|
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '+' | '.'))
|
||||||
|
}
|
||||||
|
|
||||||
/// Load background texture from filesystem.
|
/// Load background texture from filesystem.
|
||||||
pub fn load_background_texture(bg_path: &Path) -> Option<gdk::Texture> {
|
pub fn load_background_texture(bg_path: &Path) -> Option<gdk::Texture> {
|
||||||
if let Ok(meta) = std::fs::metadata(bg_path)
|
if let Ok(meta) = std::fs::symlink_metadata(bg_path) {
|
||||||
&& meta.len() > MAX_WALLPAPER_FILE_SIZE
|
if meta.file_type().is_symlink() {
|
||||||
{
|
log::warn!("Rejecting symlink wallpaper: {}", bg_path.display());
|
||||||
log::warn!(
|
return None;
|
||||||
"Wallpaper file too large ({} bytes), skipping: {}",
|
}
|
||||||
meta.len(), bg_path.display()
|
if meta.len() > MAX_WALLPAPER_FILE_SIZE {
|
||||||
);
|
log::warn!(
|
||||||
return None;
|
"Wallpaper file too large ({} bytes), skipping: {}",
|
||||||
|
meta.len(), bg_path.display()
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
match gdk::Texture::from_filename(bg_path) {
|
match gdk::Texture::from_filename(bg_path) {
|
||||||
Ok(texture) => Some(texture),
|
Ok(texture) => Some(texture),
|
||||||
@@ -118,11 +133,22 @@ pub fn load_background_texture(bg_path: &Path) -> Option<gdk::Texture> {
|
|||||||
|
|
||||||
// -- GPU blur via GskBlurNode -------------------------------------------------
|
// -- GPU blur via GskBlurNode -------------------------------------------------
|
||||||
|
|
||||||
|
// SYNC: MAX_BLUR_DIMENSION, render_blurred_texture, and create_background_picture
|
||||||
|
// are duplicated in moonlock/src/lockscreen.rs and moonset/src/panel.rs.
|
||||||
|
// Changes here must be mirrored to the other two projects.
|
||||||
|
|
||||||
|
/// 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.
|
/// Render a blurred texture using the GPU via GskBlurNode.
|
||||||
///
|
///
|
||||||
/// To avoid edge darkening (blur samples transparent pixels outside bounds),
|
/// To avoid edge darkening (blur samples transparent pixels outside bounds),
|
||||||
/// the texture is rendered with padding equal to 3x the blur sigma. The blur
|
/// 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.
|
/// 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(
|
fn render_blurred_texture(
|
||||||
widget: &impl IsA<gtk::Widget>,
|
widget: &impl IsA<gtk::Widget>,
|
||||||
texture: &gdk::Texture,
|
texture: &gdk::Texture,
|
||||||
@@ -131,17 +157,29 @@ fn render_blurred_texture(
|
|||||||
let native = widget.native()?;
|
let native = widget.native()?;
|
||||||
let renderer = native.renderer()?;
|
let renderer = native.renderer()?;
|
||||||
|
|
||||||
let w = texture.width() as f32;
|
let orig_w = texture.width() as f32;
|
||||||
let h = texture.height() 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)
|
// 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();
|
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_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)
|
// 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(); // blur
|
||||||
snapshot.pop(); // clip
|
snapshot.pop(); // clip
|
||||||
|
|
||||||
@@ -154,6 +192,7 @@ fn render_blurred_texture(
|
|||||||
pub fn create_wallpaper_window(
|
pub fn create_wallpaper_window(
|
||||||
texture: &gdk::Texture,
|
texture: &gdk::Texture,
|
||||||
blur_radius: Option<f32>,
|
blur_radius: Option<f32>,
|
||||||
|
blur_cache: &Rc<RefCell<Option<gdk::Texture>>>,
|
||||||
app: >k::Application,
|
app: >k::Application,
|
||||||
) -> gtk::ApplicationWindow {
|
) -> gtk::ApplicationWindow {
|
||||||
let window = gtk::ApplicationWindow::builder()
|
let window = gtk::ApplicationWindow::builder()
|
||||||
@@ -161,14 +200,19 @@ pub fn create_wallpaper_window(
|
|||||||
.build();
|
.build();
|
||||||
window.add_css_class("wallpaper");
|
window.add_css_class("wallpaper");
|
||||||
|
|
||||||
let background = create_background_picture(texture, blur_radius);
|
let background = create_background_picture(texture, blur_radius, blur_cache);
|
||||||
window.set_child(Some(&background));
|
window.set_child(Some(&background));
|
||||||
|
|
||||||
window
|
window
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a Picture widget for the wallpaper background, optionally with GPU blur.
|
/// 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 {
|
/// Uses `blur_cache` to compute the blurred texture only once across all monitors.
|
||||||
|
fn create_background_picture(
|
||||||
|
texture: &gdk::Texture,
|
||||||
|
blur_radius: Option<f32>,
|
||||||
|
blur_cache: &Rc<RefCell<Option<gdk::Texture>>>,
|
||||||
|
) -> gtk::Picture {
|
||||||
let background = gtk::Picture::for_paintable(texture);
|
let background = gtk::Picture::for_paintable(texture);
|
||||||
background.set_content_fit(gtk::ContentFit::Cover);
|
background.set_content_fit(gtk::ContentFit::Cover);
|
||||||
background.set_hexpand(true);
|
background.set_hexpand(true);
|
||||||
@@ -176,9 +220,16 @@ fn create_background_picture(texture: &gdk::Texture, blur_radius: Option<f32>) -
|
|||||||
|
|
||||||
if let Some(sigma) = blur_radius.filter(|s| *s > 0.0) {
|
if let Some(sigma) = blur_radius.filter(|s| *s > 0.0) {
|
||||||
let texture = texture.clone();
|
let texture = texture.clone();
|
||||||
|
let blur_cache = blur_cache.clone();
|
||||||
background.connect_realize(move |picture| {
|
background.connect_realize(move |picture| {
|
||||||
|
// Use cached blurred texture if available
|
||||||
|
if let Some(ref cached) = *blur_cache.borrow() {
|
||||||
|
picture.set_paintable(Some(cached));
|
||||||
|
return;
|
||||||
|
}
|
||||||
if let Some(blurred) = render_blurred_texture(picture, &texture, sigma) {
|
if let Some(blurred) = render_blurred_texture(picture, &texture, sigma) {
|
||||||
picture.set_paintable(Some(&blurred));
|
picture.set_paintable(Some(&blurred));
|
||||||
|
*blur_cache.borrow_mut() = Some(blurred);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -193,14 +244,20 @@ struct GreeterState {
|
|||||||
default_avatar_texture: Option<gdk::Texture>,
|
default_avatar_texture: Option<gdk::Texture>,
|
||||||
failed_attempts: HashMap<String, u32>,
|
failed_attempts: HashMap<String, u32>,
|
||||||
greetd_sock: Arc<Mutex<Option<UnixStream>>>,
|
greetd_sock: Arc<Mutex<Option<UnixStream>>>,
|
||||||
|
greetd_sock_path: Option<String>,
|
||||||
login_cancelled: Arc<std::sync::atomic::AtomicBool>,
|
login_cancelled: Arc<std::sync::atomic::AtomicBool>,
|
||||||
fingerprint_available: bool,
|
fingerprint_available: bool,
|
||||||
|
/// Incremented on each user switch to discard stale async results.
|
||||||
|
user_switch_generation: u64,
|
||||||
|
/// Cached fprintd device proxy — initialized once on first use.
|
||||||
|
fingerprint_probe: Option<crate::fingerprint::FingerprintProbe>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create the main greeter window with login UI.
|
/// Create the main greeter window with login UI.
|
||||||
pub fn create_greeter_window(
|
pub fn create_greeter_window(
|
||||||
texture: Option<&gdk::Texture>,
|
texture: Option<&gdk::Texture>,
|
||||||
config: &Config,
|
config: &Config,
|
||||||
|
blur_cache: &Rc<RefCell<Option<gdk::Texture>>>,
|
||||||
app: >k::Application,
|
app: >k::Application,
|
||||||
) -> gtk::ApplicationWindow {
|
) -> gtk::ApplicationWindow {
|
||||||
let window = gtk::ApplicationWindow::builder()
|
let window = gtk::ApplicationWindow::builder()
|
||||||
@@ -211,11 +268,7 @@ pub fn create_greeter_window(
|
|||||||
|
|
||||||
// Apply GTK theme from config
|
// Apply GTK theme from config
|
||||||
if let Some(ref theme_name) = config.gtk_theme {
|
if let Some(ref theme_name) = config.gtk_theme {
|
||||||
if !theme_name.is_empty()
|
if is_valid_gtk_theme(theme_name) {
|
||||||
&& theme_name
|
|
||||||
.chars()
|
|
||||||
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '+' | '.'))
|
|
||||||
{
|
|
||||||
if let Some(settings) = gtk::Settings::default() {
|
if let Some(settings) = gtk::Settings::default() {
|
||||||
settings.set_gtk_theme_name(Some(theme_name));
|
settings.set_gtk_theme_name(Some(theme_name));
|
||||||
}
|
}
|
||||||
@@ -233,14 +286,20 @@ pub fn create_greeter_window(
|
|||||||
log::debug!("GTK theme: {theme}");
|
log::debug!("GTK theme: {theme}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cache GREETD_SOCK at startup — it never changes during runtime
|
||||||
|
let greetd_sock_path = std::env::var("GREETD_SOCK").ok().filter(|p| !p.is_empty());
|
||||||
|
|
||||||
let state = Rc::new(RefCell::new(GreeterState {
|
let state = Rc::new(RefCell::new(GreeterState {
|
||||||
selected_user: None,
|
selected_user: None,
|
||||||
avatar_cache: HashMap::new(),
|
avatar_cache: HashMap::new(),
|
||||||
default_avatar_texture: None,
|
default_avatar_texture: None,
|
||||||
failed_attempts: HashMap::new(),
|
failed_attempts: HashMap::new(),
|
||||||
greetd_sock: Arc::new(Mutex::new(None)),
|
greetd_sock: Arc::new(Mutex::new(None)),
|
||||||
|
greetd_sock_path,
|
||||||
login_cancelled: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
login_cancelled: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||||
fingerprint_available: false,
|
fingerprint_available: false,
|
||||||
|
user_switch_generation: 0,
|
||||||
|
fingerprint_probe: None,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Root overlay for layering
|
// Root overlay for layering
|
||||||
@@ -249,7 +308,7 @@ pub fn create_greeter_window(
|
|||||||
|
|
||||||
// Background wallpaper
|
// Background wallpaper
|
||||||
if let Some(texture) = texture {
|
if let Some(texture) = texture {
|
||||||
overlay.set_child(Some(&create_background_picture(texture, config.background_blur)));
|
overlay.set_child(Some(&create_background_picture(texture, config.background_blur, blur_cache)));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Main layout: 3 rows (top spacer, center login, bottom bar)
|
// Main layout: 3 rows (top spacer, center login, bottom bar)
|
||||||
@@ -451,7 +510,7 @@ pub fn create_greeter_window(
|
|||||||
};
|
};
|
||||||
let Some(user) = user else { return };
|
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 session = get_selected_session(&session_dropdown, &sessions_rc);
|
||||||
let Some(session) = session else {
|
let Some(session) = session else {
|
||||||
@@ -559,6 +618,7 @@ pub fn create_greeter_window(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Select the last user or the first available user.
|
/// Select the last user or the first available user.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn select_initial_user(
|
fn select_initial_user(
|
||||||
users: &[User],
|
users: &[User],
|
||||||
state: &Rc<RefCell<GreeterState>>,
|
state: &Rc<RefCell<GreeterState>>,
|
||||||
@@ -601,6 +661,7 @@ fn select_initial_user(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Update the UI to show the selected user.
|
/// Update the UI to show the selected user.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn switch_to_user(
|
fn switch_to_user(
|
||||||
user: &User,
|
user: &User,
|
||||||
state: &Rc<RefCell<GreeterState>>,
|
state: &Rc<RefCell<GreeterState>>,
|
||||||
@@ -616,11 +677,13 @@ fn switch_to_user(
|
|||||||
strings: &'static Strings,
|
strings: &'static Strings,
|
||||||
) {
|
) {
|
||||||
log::debug!("Switching to user: {}", user.username);
|
log::debug!("Switching to user: {}", user.username);
|
||||||
{
|
let generation = {
|
||||||
let mut s = state.borrow_mut();
|
let mut s = state.borrow_mut();
|
||||||
s.selected_user = Some(user.clone());
|
s.selected_user = Some(user.clone());
|
||||||
s.fingerprint_available = false;
|
s.fingerprint_available = false;
|
||||||
}
|
s.user_switch_generation += 1;
|
||||||
|
s.user_switch_generation
|
||||||
|
};
|
||||||
|
|
||||||
username_label.set_text(user.display_name());
|
username_label.set_text(user.display_name());
|
||||||
password_entry.set_text("");
|
password_entry.set_text("");
|
||||||
@@ -650,7 +713,7 @@ fn switch_to_user(
|
|||||||
// Pre-select last used session for this user
|
// Pre-select last used session for this user
|
||||||
select_last_session(&user.username, session_dropdown, sessions);
|
select_last_session(&user.username, session_dropdown, sessions);
|
||||||
|
|
||||||
// Probe fprintd for fingerprint availability
|
// Probe fprintd for fingerprint availability (cached device proxy, generation-guarded)
|
||||||
if fingerprint_enabled {
|
if fingerprint_enabled {
|
||||||
let username = user.username.clone();
|
let username = user.username.clone();
|
||||||
glib::spawn_future_local(clone!(
|
glib::spawn_future_local(clone!(
|
||||||
@@ -659,9 +722,29 @@ fn switch_to_user(
|
|||||||
#[strong]
|
#[strong]
|
||||||
state,
|
state,
|
||||||
async move {
|
async move {
|
||||||
let mut probe = crate::fingerprint::FingerprintProbe::new();
|
// Initialize probe on first use, then reuse cached device proxy
|
||||||
probe.init_async().await;
|
let needs_init = state.borrow().fingerprint_probe.is_none();
|
||||||
let available = probe.is_available_async(&username).await;
|
if needs_init {
|
||||||
|
let mut probe = crate::fingerprint::FingerprintProbe::new();
|
||||||
|
probe.init_async().await;
|
||||||
|
state.borrow_mut().fingerprint_probe = Some(probe);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Take probe out of state to avoid holding borrow across await
|
||||||
|
let probe = state.borrow_mut().fingerprint_probe.take();
|
||||||
|
let available = match &probe {
|
||||||
|
Some(p) => p.is_available_async(&username).await,
|
||||||
|
None => false,
|
||||||
|
};
|
||||||
|
state.borrow_mut().fingerprint_probe = probe;
|
||||||
|
|
||||||
|
// Discard result if user switched while we were probing
|
||||||
|
let s = state.borrow();
|
||||||
|
if s.user_switch_generation != generation {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
drop(s);
|
||||||
|
|
||||||
state.borrow_mut().fingerprint_available = available;
|
state.borrow_mut().fingerprint_available = available;
|
||||||
fp_label.set_visible(available);
|
fp_label.set_visible(available);
|
||||||
if available {
|
if available {
|
||||||
@@ -834,15 +917,19 @@ fn extract_greetd_description<'a>(response: &'a serde_json::Value, fallback: &'a
|
|||||||
.unwrap_or(fallback)
|
.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(
|
fn show_greetd_error(
|
||||||
error_label: >k::Label,
|
error_label: >k::Label,
|
||||||
password_entry: >k::PasswordEntry,
|
password_entry: >k::PasswordEntry,
|
||||||
response: &serde_json::Value,
|
response: &serde_json::Value,
|
||||||
fallback: &str,
|
fallback: &str,
|
||||||
) {
|
) {
|
||||||
let message = extract_greetd_description(response, fallback);
|
let raw = extract_greetd_description(response, fallback);
|
||||||
show_error(error_label, password_entry, message);
|
if raw != fallback {
|
||||||
|
log::debug!("greetd error detail: {raw}");
|
||||||
|
}
|
||||||
|
show_error(error_label, password_entry, fallback);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cancel any in-progress greetd session.
|
/// Cancel any in-progress greetd session.
|
||||||
@@ -851,10 +938,10 @@ fn cancel_pending_session(state: &Rc<RefCell<GreeterState>>) {
|
|||||||
let s = state.borrow();
|
let s = state.borrow();
|
||||||
s.login_cancelled
|
s.login_cancelled
|
||||||
.store(true, std::sync::atomic::Ordering::SeqCst);
|
.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||||
if let Ok(mut sock_guard) = s.greetd_sock.lock() {
|
if let Ok(mut sock_guard) = s.greetd_sock.lock()
|
||||||
if let Some(sock) = sock_guard.take() {
|
&& let Some(sock) = sock_guard.take()
|
||||||
let _ = sock.shutdown(std::net::Shutdown::Both);
|
{
|
||||||
}
|
let _ = sock.shutdown(std::net::Shutdown::Both);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -882,9 +969,9 @@ fn attempt_login(
|
|||||||
session_dropdown: >k::DropDown,
|
session_dropdown: >k::DropDown,
|
||||||
) {
|
) {
|
||||||
log::debug!("Login attempt for user: {}", user.username);
|
log::debug!("Login attempt for user: {}", user.username);
|
||||||
let sock_path = match std::env::var("GREETD_SOCK") {
|
let sock_path = match state.borrow().greetd_sock_path.clone() {
|
||||||
Ok(p) if !p.is_empty() => p,
|
Some(p) => p,
|
||||||
_ => {
|
None => {
|
||||||
show_error(error_label, password_entry, strings.greetd_sock_not_set);
|
show_error(error_label, password_entry, strings.greetd_sock_not_set);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -902,28 +989,6 @@ fn attempt_login(
|
|||||||
return;
|
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
|
// Reset cancellation flag and disable UI
|
||||||
{
|
{
|
||||||
let s = state.borrow();
|
let s = state.borrow();
|
||||||
@@ -933,7 +998,7 @@ fn attempt_login(
|
|||||||
set_login_sensitive(password_entry, session_dropdown, false);
|
set_login_sensitive(password_entry, session_dropdown, false);
|
||||||
|
|
||||||
let username = user.username.clone();
|
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 exec_cmd = session.exec_cmd.clone();
|
||||||
let session_name = session.name.clone();
|
let session_name = session.name.clone();
|
||||||
let greetd_sock = state.borrow().greetd_sock.clone();
|
let greetd_sock = state.borrow().greetd_sock.clone();
|
||||||
@@ -953,6 +1018,8 @@ fn attempt_login(
|
|||||||
state,
|
state,
|
||||||
async move {
|
async move {
|
||||||
let session_name_clone = session_name.clone();
|
let session_name_clone = session_name.clone();
|
||||||
|
// Minimum response time to prevent username enumeration via timing
|
||||||
|
let login_start = std::time::Instant::now();
|
||||||
let result = gio::spawn_blocking(move || {
|
let result = gio::spawn_blocking(move || {
|
||||||
login_worker(
|
login_worker(
|
||||||
&username,
|
&username,
|
||||||
@@ -966,6 +1033,11 @@ fn attempt_login(
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
|
let elapsed = login_start.elapsed();
|
||||||
|
let min_response = std::time::Duration::from_millis(500);
|
||||||
|
if elapsed < min_response {
|
||||||
|
glib::timeout_future(min_response - elapsed).await;
|
||||||
|
}
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
Ok(Ok(LoginResult::Success { username })) => {
|
Ok(Ok(LoginResult::Success { username })) => {
|
||||||
@@ -1030,6 +1102,7 @@ enum LoginResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Run greetd IPC in a background thread.
|
/// Run greetd IPC in a background thread.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn login_worker(
|
fn login_worker(
|
||||||
username: &str,
|
username: &str,
|
||||||
password: &str,
|
password: &str,
|
||||||
@@ -1076,8 +1149,11 @@ fn login_worker(
|
|||||||
return Ok(LoginResult::Cancelled);
|
return Ok(LoginResult::Cancelled);
|
||||||
}
|
}
|
||||||
if response.get("type").and_then(|v| v.as_str()) == Some("error") {
|
if response.get("type").and_then(|v| v.as_str()) == Some("error") {
|
||||||
let message = extract_greetd_description(&response, strings.auth_failed).to_string();
|
let raw = extract_greetd_description(&response, strings.auth_failed);
|
||||||
return Ok(LoginResult::Error { message });
|
if raw != strings.auth_failed {
|
||||||
|
log::debug!("greetd error detail: {raw}");
|
||||||
|
}
|
||||||
|
return Ok(LoginResult::Error { message: strings.auth_failed.to_string() });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1165,9 +1241,12 @@ fn login_worker(
|
|||||||
username: username.to_string(),
|
username: username.to_string(),
|
||||||
});
|
});
|
||||||
} else {
|
} 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 {
|
return Ok(LoginResult::Error {
|
||||||
message: extract_greetd_description(&response, strings.session_start_failed)
|
message: strings.session_start_failed.to_string(),
|
||||||
.to_string(),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1190,7 +1269,7 @@ fn execute_power_action(
|
|||||||
#[weak]
|
#[weak]
|
||||||
button,
|
button,
|
||||||
async move {
|
async move {
|
||||||
let result = gio::spawn_blocking(move || action_fn()).await;
|
let result = gio::spawn_blocking(action_fn).await;
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
Ok(Ok(())) => {}
|
Ok(Ok(())) => {}
|
||||||
@@ -1213,6 +1292,15 @@ fn execute_power_action(
|
|||||||
|
|
||||||
// -- Last user/session persistence --
|
// -- Last user/session persistence --
|
||||||
|
|
||||||
|
/// Create a cache directory with restricted permissions (0o700).
|
||||||
|
fn create_cache_dir(path: &Path) -> std::io::Result<()> {
|
||||||
|
use std::os::unix::fs::DirBuilderExt;
|
||||||
|
std::fs::DirBuilder::new()
|
||||||
|
.recursive(true)
|
||||||
|
.mode(0o700)
|
||||||
|
.create(path)
|
||||||
|
}
|
||||||
|
|
||||||
fn load_last_user() -> Option<String> {
|
fn load_last_user() -> Option<String> {
|
||||||
load_last_user_from(Path::new(LAST_USER_PATH))
|
load_last_user_from(Path::new(LAST_USER_PATH))
|
||||||
}
|
}
|
||||||
@@ -1236,7 +1324,7 @@ fn save_last_user(username: &str) {
|
|||||||
fn save_last_user_to(path: &Path, username: &str) {
|
fn save_last_user_to(path: &Path, username: &str) {
|
||||||
log::debug!("Saving last user: {username}");
|
log::debug!("Saving last user: {username}");
|
||||||
if let Some(parent) = path.parent()
|
if let Some(parent) = path.parent()
|
||||||
&& let Err(e) = std::fs::create_dir_all(parent)
|
&& let Err(e) = create_cache_dir(parent)
|
||||||
{
|
{
|
||||||
log::warn!("Failed to create cache dir {}: {e}", parent.display());
|
log::warn!("Failed to create cache dir {}: {e}", parent.display());
|
||||||
return;
|
return;
|
||||||
@@ -1289,7 +1377,10 @@ fn save_last_session(username: &str, session_name: &str) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let dir = Path::new(LAST_SESSION_DIR);
|
let dir = Path::new(LAST_SESSION_DIR);
|
||||||
let _ = std::fs::create_dir_all(dir);
|
if let Err(e) = create_cache_dir(dir) {
|
||||||
|
log::warn!("Failed to create session cache dir {}: {e}", dir.display());
|
||||||
|
return;
|
||||||
|
}
|
||||||
save_last_session_to(&dir.join(username), session_name);
|
save_last_session_to(&dir.join(username), session_name);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1884,4 +1975,55 @@ mod tests {
|
|||||||
let resp = serde_json::json!({"type": "error"});
|
let resp = serde_json::json!({"type": "error"});
|
||||||
assert_eq!(extract_greetd_description(&resp, "fallback"), "fallback");
|
assert_eq!(extract_greetd_description(&resp, "fallback"), "fallback");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -- GTK theme validation --
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn valid_gtk_themes() {
|
||||||
|
assert!(is_valid_gtk_theme("Adwaita"));
|
||||||
|
assert!(is_valid_gtk_theme("Catppuccin-Mocha"));
|
||||||
|
assert!(is_valid_gtk_theme("Arc_Dark"));
|
||||||
|
assert!(is_valid_gtk_theme("Theme+Variant"));
|
||||||
|
assert!(is_valid_gtk_theme("v1.0"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_gtk_themes() {
|
||||||
|
assert!(!is_valid_gtk_theme(""));
|
||||||
|
assert!(!is_valid_gtk_theme("../evil"));
|
||||||
|
assert!(!is_valid_gtk_theme("theme/path"));
|
||||||
|
assert!(!is_valid_gtk_theme("theme name"));
|
||||||
|
assert!(!is_valid_gtk_theme("thème"));
|
||||||
|
assert!(!is_valid_gtk_theme("theme\0null"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Username validation: Unicode edge cases --
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_unicode_usernames() {
|
||||||
|
assert!(!is_valid_username("üser"));
|
||||||
|
assert!(!is_valid_username("用户"));
|
||||||
|
assert!(!is_valid_username("user🔑"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Cache directory permissions --
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_cache_dir_sets_mode_0o700() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let cache_dir = tmp.path().join("cache");
|
||||||
|
create_cache_dir(&cache_dir).unwrap();
|
||||||
|
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let mode = std::fs::metadata(&cache_dir).unwrap().permissions().mode() & 0o777;
|
||||||
|
assert_eq!(mode, 0o700, "Cache dir should be 0o700, got {mode:#o}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn save_last_session_with_unwritable_dir() {
|
||||||
|
// Attempt to save in a non-existent dir under /proc (guaranteed unwritable)
|
||||||
|
let path = Path::new("/proc/nonexistent-moongreet-test/session");
|
||||||
|
save_last_session_to(path, "niri");
|
||||||
|
// Should not panic — just logs a warning
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-9
@@ -4,6 +4,7 @@
|
|||||||
use std::env;
|
use std::env;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
const DEFAULT_LOCALE_CONF: &str = "/etc/locale.conf";
|
const DEFAULT_LOCALE_CONF: &str = "/etc/locale.conf";
|
||||||
|
|
||||||
@@ -19,8 +20,6 @@ pub struct Strings {
|
|||||||
pub no_session_selected: &'static str,
|
pub no_session_selected: &'static str,
|
||||||
pub greetd_sock_not_set: &'static str,
|
pub greetd_sock_not_set: &'static str,
|
||||||
pub greetd_sock_not_absolute: &'static str,
|
pub greetd_sock_not_absolute: &'static str,
|
||||||
pub greetd_sock_not_socket: &'static str,
|
|
||||||
pub greetd_sock_unreachable: &'static str,
|
|
||||||
pub auth_failed: &'static str,
|
pub auth_failed: &'static str,
|
||||||
pub wrong_password: &'static str,
|
pub wrong_password: &'static str,
|
||||||
pub fingerprint_prompt: &'static str,
|
pub fingerprint_prompt: &'static str,
|
||||||
@@ -43,8 +42,6 @@ const STRINGS_DE: Strings = Strings {
|
|||||||
no_session_selected: "Keine Session ausgewählt",
|
no_session_selected: "Keine Session ausgewählt",
|
||||||
greetd_sock_not_set: "GREETD_SOCK nicht gesetzt",
|
greetd_sock_not_set: "GREETD_SOCK nicht gesetzt",
|
||||||
greetd_sock_not_absolute: "GREETD_SOCK ist kein absoluter Pfad",
|
greetd_sock_not_absolute: "GREETD_SOCK ist kein absoluter Pfad",
|
||||||
greetd_sock_not_socket: "GREETD_SOCK zeigt nicht auf einen Socket",
|
|
||||||
greetd_sock_unreachable: "GREETD_SOCK nicht erreichbar",
|
|
||||||
auth_failed: "Authentifizierung fehlgeschlagen",
|
auth_failed: "Authentifizierung fehlgeschlagen",
|
||||||
wrong_password: "Falsches Passwort",
|
wrong_password: "Falsches Passwort",
|
||||||
fingerprint_prompt: "Fingerabdruck auflegen oder Passwort eingeben",
|
fingerprint_prompt: "Fingerabdruck auflegen oder Passwort eingeben",
|
||||||
@@ -65,8 +62,6 @@ const STRINGS_EN: Strings = Strings {
|
|||||||
no_session_selected: "No session selected",
|
no_session_selected: "No session selected",
|
||||||
greetd_sock_not_set: "GREETD_SOCK not set",
|
greetd_sock_not_set: "GREETD_SOCK not set",
|
||||||
greetd_sock_not_absolute: "GREETD_SOCK is not an absolute path",
|
greetd_sock_not_absolute: "GREETD_SOCK is not an absolute path",
|
||||||
greetd_sock_not_socket: "GREETD_SOCK does not point to a socket",
|
|
||||||
greetd_sock_unreachable: "GREETD_SOCK unreachable",
|
|
||||||
auth_failed: "Authentication failed",
|
auth_failed: "Authentication failed",
|
||||||
wrong_password: "Wrong password",
|
wrong_password: "Wrong password",
|
||||||
fingerprint_prompt: "Place finger on reader or enter password",
|
fingerprint_prompt: "Place finger on reader or enter password",
|
||||||
@@ -135,14 +130,17 @@ pub fn detect_locale() -> String {
|
|||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cached locale — detected once, reused for the lifetime of the process.
|
||||||
|
static CACHED_LOCALE: OnceLock<String> = OnceLock::new();
|
||||||
|
|
||||||
/// Return the string table for the given locale, defaulting to English.
|
/// Return the string table for the given locale, defaulting to English.
|
||||||
pub fn load_strings(locale: Option<&str>) -> &'static Strings {
|
pub fn load_strings(locale: Option<&str>) -> &'static Strings {
|
||||||
let locale = match locale {
|
let locale = match locale {
|
||||||
Some(l) => l.to_string(),
|
Some(l) => l,
|
||||||
None => detect_locale(),
|
None => CACHED_LOCALE.get_or_init(detect_locale),
|
||||||
};
|
};
|
||||||
|
|
||||||
match locale.as_str() {
|
match locale {
|
||||||
"de" => &STRINGS_DE,
|
"de" => &STRINGS_DE,
|
||||||
_ => &STRINGS_EN,
|
_ => &STRINGS_EN,
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-2
@@ -58,11 +58,13 @@ fn activate(app: >k::Application) {
|
|||||||
greeter::load_background_texture(&path)
|
greeter::load_background_texture(&path)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let blur_cache = std::rc::Rc::new(std::cell::RefCell::new(None));
|
||||||
|
|
||||||
let use_layer_shell = std::env::var("MOONGREET_NO_LAYER_SHELL").is_err();
|
let use_layer_shell = std::env::var("MOONGREET_NO_LAYER_SHELL").is_err();
|
||||||
log::debug!("Layer shell: {use_layer_shell}");
|
log::debug!("Layer shell: {use_layer_shell}");
|
||||||
|
|
||||||
// Main greeter window (login UI) — compositor picks focused monitor
|
// Main greeter window (login UI) — compositor picks focused monitor
|
||||||
let greeter_window = greeter::create_greeter_window(bg_texture.as_ref(), &config, app);
|
let greeter_window = greeter::create_greeter_window(bg_texture.as_ref(), &config, &blur_cache, app);
|
||||||
if use_layer_shell {
|
if use_layer_shell {
|
||||||
setup_layer_shell(&greeter_window, true, gtk4_layer_shell::Layer::Top);
|
setup_layer_shell(&greeter_window, true, gtk4_layer_shell::Layer::Top);
|
||||||
}
|
}
|
||||||
@@ -79,7 +81,7 @@ fn activate(app: >k::Application) {
|
|||||||
.item(i)
|
.item(i)
|
||||||
.and_then(|obj| obj.downcast::<gdk::Monitor>().ok())
|
.and_then(|obj| obj.downcast::<gdk::Monitor>().ok())
|
||||||
{
|
{
|
||||||
let wallpaper = greeter::create_wallpaper_window(texture, config.background_blur, app);
|
let wallpaper = greeter::create_wallpaper_window(texture, config.background_blur, &blur_cache, app);
|
||||||
setup_layer_shell(&wallpaper, false, gtk4_layer_shell::Layer::Bottom);
|
setup_layer_shell(&wallpaper, false, gtk4_layer_shell::Layer::Bottom);
|
||||||
wallpaper.set_monitor(Some(&monitor));
|
wallpaper.set_monitor(Some(&monitor));
|
||||||
wallpaper.present();
|
wallpaper.present();
|
||||||
|
|||||||
+70
-19
@@ -2,11 +2,18 @@
|
|||||||
// ABOUTME: Wrappers around system commands for the greeter UI.
|
// ABOUTME: Wrappers around system commands for the greeter UI.
|
||||||
|
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use std::process::Command;
|
use std::io::Read;
|
||||||
|
use std::process::{Command, Stdio};
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
const POWER_TIMEOUT: Duration = Duration::from_secs(30);
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum PowerError {
|
pub enum PowerError {
|
||||||
CommandFailed { action: &'static str, message: String },
|
CommandFailed { action: &'static str, message: String },
|
||||||
|
Timeout { action: &'static str },
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Display for PowerError {
|
impl fmt::Display for PowerError {
|
||||||
@@ -15,41 +22,79 @@ impl fmt::Display for PowerError {
|
|||||||
PowerError::CommandFailed { action, message } => {
|
PowerError::CommandFailed { action, message } => {
|
||||||
write!(f, "{action} failed: {message}")
|
write!(f, "{action} failed: {message}")
|
||||||
}
|
}
|
||||||
|
PowerError::Timeout { action } => {
|
||||||
|
write!(f, "{action} timed out")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::error::Error for PowerError {}
|
impl std::error::Error for PowerError {}
|
||||||
|
|
||||||
/// Run a command and return a PowerError on failure.
|
/// Run a command with timeout and return a PowerError on failure.
|
||||||
|
///
|
||||||
|
/// Uses blocking `child.wait()` with a separate timeout thread that sends
|
||||||
|
/// SIGKILL after POWER_TIMEOUT. This runs inside `gio::spawn_blocking`,
|
||||||
|
/// so blocking is expected.
|
||||||
fn run_command(action: &'static str, program: &str, args: &[&str]) -> Result<(), PowerError> {
|
fn run_command(action: &'static str, program: &str, args: &[&str]) -> Result<(), PowerError> {
|
||||||
log::debug!("Power action: {action} ({program} {args:?})");
|
log::debug!("Power action: {action} ({program} {args:?})");
|
||||||
let child = Command::new(program)
|
let mut child = Command::new(program)
|
||||||
.args(args)
|
.args(args)
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
.spawn()
|
.spawn()
|
||||||
.map_err(|e| PowerError::CommandFailed {
|
.map_err(|e| PowerError::CommandFailed {
|
||||||
action,
|
action,
|
||||||
message: e.to_string(),
|
message: e.to_string(),
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let output = child
|
let child_pid = nix::unistd::Pid::from_raw(child.id() as i32);
|
||||||
.wait_with_output()
|
let done = Arc::new(AtomicBool::new(false));
|
||||||
.map_err(|e| PowerError::CommandFailed {
|
let done_clone = done.clone();
|
||||||
action,
|
|
||||||
message: e.to_string(),
|
|
||||||
})?;
|
|
||||||
|
|
||||||
if output.status.success() {
|
let timeout_thread = std::thread::spawn(move || {
|
||||||
log::debug!("Power action {action} completed successfully");
|
let interval = Duration::from_millis(100);
|
||||||
|
let mut elapsed = Duration::ZERO;
|
||||||
|
while elapsed < POWER_TIMEOUT {
|
||||||
|
std::thread::sleep(interval);
|
||||||
|
if done_clone.load(Ordering::Relaxed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
elapsed += interval;
|
||||||
|
}
|
||||||
|
// ESRCH if the process already exited — harmless
|
||||||
|
let _ = nix::sys::signal::kill(child_pid, nix::sys::signal::Signal::SIGKILL);
|
||||||
|
});
|
||||||
|
|
||||||
|
let status = child.wait().map_err(|e| PowerError::CommandFailed {
|
||||||
|
action,
|
||||||
|
message: e.to_string(),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
done.store(true, Ordering::Relaxed);
|
||||||
|
let _ = timeout_thread.join();
|
||||||
|
|
||||||
|
if status.success() {
|
||||||
|
log::debug!("Power action {action} completed");
|
||||||
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
#[cfg(unix)]
|
||||||
return Err(PowerError::CommandFailed {
|
{
|
||||||
action,
|
use std::os::unix::process::ExitStatusExt;
|
||||||
message: format!("exit code {}: {}", output.status, stderr.trim()),
|
if status.signal() == Some(9) {
|
||||||
});
|
return Err(PowerError::Timeout { action });
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
let mut stderr_buf = String::new();
|
||||||
|
if let Some(mut stderr) = child.stderr.take() {
|
||||||
|
let _ = stderr.read_to_string(&mut stderr_buf);
|
||||||
|
}
|
||||||
|
Err(PowerError::CommandFailed {
|
||||||
|
action,
|
||||||
|
message: format!("exit code {}: {}", status, stderr_buf.trim()),
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reboot the system via loginctl.
|
/// Reboot the system via loginctl.
|
||||||
@@ -75,6 +120,12 @@ mod tests {
|
|||||||
assert_eq!(err.to_string(), "reboot failed: No such file or directory");
|
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]
|
#[test]
|
||||||
fn run_command_returns_error_for_missing_binary() {
|
fn run_command_returns_error_for_missing_binary() {
|
||||||
let result = run_command("test", "nonexistent-binary-xyz", &[]);
|
let result = run_command("test", "nonexistent-binary-xyz", &[]);
|
||||||
@@ -99,7 +150,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn run_command_passes_args() {
|
fn run_command_passes_args() {
|
||||||
let result = run_command("test", "true", &["--ignored-arg"]);
|
let result = run_command("test", "echo", &["hello", "world"]);
|
||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-8
@@ -36,14 +36,14 @@ fn parse_desktop_file(path: &Path, session_type: &str) -> Option<Session> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(value) = line.strip_prefix("Name=") {
|
if let Some(value) = line.strip_prefix("Name=")
|
||||||
if name.is_none() {
|
&& name.is_none()
|
||||||
name = Some(value.to_string());
|
{
|
||||||
}
|
name = Some(value.to_string());
|
||||||
} else if let Some(value) = line.strip_prefix("Exec=") {
|
} else if let Some(value) = line.strip_prefix("Exec=")
|
||||||
if exec_cmd.is_none() {
|
&& exec_cmd.is_none()
|
||||||
exec_cmd = Some(value.to_string());
|
{
|
||||||
}
|
exec_cmd = Some(value.to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+17
-17
@@ -70,7 +70,7 @@ pub fn get_users(passwd_path: Option<&Path>) -> Vec<User> {
|
|||||||
Err(_) => continue,
|
Err(_) => continue,
|
||||||
};
|
};
|
||||||
|
|
||||||
if uid < MIN_UID || uid > MAX_UID {
|
if !(MIN_UID..=MAX_UID).contains(&uid) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if NOLOGIN_SHELLS.contains(&shell) {
|
if NOLOGIN_SHELLS.contains(&shell) {
|
||||||
@@ -94,7 +94,7 @@ pub fn get_users(passwd_path: Option<&Path>) -> Vec<User> {
|
|||||||
users
|
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.
|
/// Rejects symlinks to prevent path traversal.
|
||||||
pub fn get_avatar_path(username: &str, home: &Path) -> Option<PathBuf> {
|
pub fn get_avatar_path(username: &str, home: &Path) -> Option<PathBuf> {
|
||||||
get_avatar_path_with(username, home, Path::new(DEFAULT_ACCOUNTSSERVICE_DIR))
|
get_avatar_path_with(username, home, Path::new(DEFAULT_ACCOUNTSSERVICE_DIR))
|
||||||
@@ -106,30 +106,30 @@ pub fn get_avatar_path_with(
|
|||||||
home: &Path,
|
home: &Path,
|
||||||
accountsservice_dir: &Path,
|
accountsservice_dir: &Path,
|
||||||
) -> Option<PathBuf> {
|
) -> Option<PathBuf> {
|
||||||
// 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() {
|
if accountsservice_dir.exists() {
|
||||||
let icon = accountsservice_dir.join(username);
|
let icon = accountsservice_dir.join(username);
|
||||||
if let Ok(meta) = icon.symlink_metadata() {
|
if let Ok(meta) = icon.symlink_metadata() {
|
||||||
if meta.file_type().is_symlink() {
|
if meta.file_type().is_symlink() {
|
||||||
log::warn!("Rejecting symlink avatar for {username}: {}", icon.display());
|
log::warn!("Rejecting symlink avatar for {username}: {}", icon.display());
|
||||||
} else {
|
} else if meta.is_file() {
|
||||||
log::debug!("Avatar for {username}: AccountsService {}", icon.display());
|
log::debug!("Avatar for {username}: AccountsService {}", icon.display());
|
||||||
return Some(icon);
|
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}");
|
log::debug!("No avatar found for {username}");
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
@@ -248,7 +248,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn accountsservice_icon_takes_priority() {
|
fn face_file_takes_priority_over_accountsservice() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let icons_dir = dir.path().join("icons");
|
let icons_dir = dir.path().join("icons");
|
||||||
fs::create_dir(&icons_dir).unwrap();
|
fs::create_dir(&icons_dir).unwrap();
|
||||||
@@ -261,7 +261,7 @@ mod tests {
|
|||||||
fs::write(&face, "fake face").unwrap();
|
fs::write(&face, "fake face").unwrap();
|
||||||
|
|
||||||
let path = get_avatar_path_with("testuser", &home, &icons_dir);
|
let path = get_avatar_path_with("testuser", &home, &icons_dir);
|
||||||
assert_eq!(path, Some(icon));
|
assert_eq!(path, Some(face));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
Reference in New Issue
Block a user