6 Commits

Author SHA1 Message Date
nevaforget 510d45a9b1 fix: audit LOW fixes — rejection-path tests, wallpaper-fallback docs (v0.9.1)
Update PKGBUILD version / update-pkgver (push) Successful in 4s
- Test AccountsService-icon symlink rejection (users.rs)
- Tests for wallpaper symlink/extension/size rejection (config.rs)
- Fix stale 'bundled package wallpaper' fallback docs (README, example config) — bundled tier removed 2026-03-28, actual chain is two-tier
2026-06-17 13:06:15 +02:00
nevaforget 7dae48f6cc feat(power): logout via loginctl, not Niri-locked
Default `loginctl terminate-session $XDG_SESSION_ID`; `logout_command`
TOML override for other compositors.
2026-06-17 12:40:24 +02:00
nevaforget 115cfe6bb1 docs: translate CLAUDE.md to English
Per the committed=English rule.
2026-06-16 10:46:13 +02:00
nevaforget 05ddbdc81e ci: switch update-pkgver to tag-trigger (no-suffix pkgname) 2026-06-10 18:41:35 +02:00
nevaforget 8285bcdf44 fix: audit LOW fixes — dead uid, home_dir warn, clippy sweep, debug value (v0.8.5)
Update PKGBUILD version / update-pkgver (push) Successful in 2s
- users::User: drop the unused `uid` field and its getuid() assignment.
  The compiler dead_code warning is gone, and the synthetic `u32::MAX`
  sentinel in the panel fallback is obsolete too.
- panel: surface a log::warn! when dirs::home_dir() returns None instead
  of silently falling back to an empty PathBuf that would make avatars
  look for .face in the current working directory.
- Apply three clippy suggestions: two collapsible if-let + && chains in
  users::get_avatar_path_with and config::resolve_background_path_with,
  and a redundant closure in panel::execute_action's spawn_blocking.
- main: require MOONSET_DEBUG=1 to escalate log verbosity — mere
  presence of the var must not dump path info into the journal.
2026-04-24 14:14:11 +02:00
nevaforget 0789e8fc27 fix: audit MEDIUM fixes — timeout guard, POSIX locale, button gate, wallpaper allowlist (v0.8.4)
- power: RAII DoneGuard sets done=true on every wait() exit path, so the
  timeout thread no longer sleeps its full 30 s holding a spawn_blocking
  slot when child.wait() errors. A separate timed_out AtomicBool marks
  our own SIGKILL so we do not misclassify an external OOM-kill. Memory
  ordering on the flags is now Release/Acquire.
- i18n: detect_locale now reads LC_ALL, LC_MESSAGES, LANG in POSIX
  priority order before falling back to /etc/locale.conf, so systems
  installed in English with LC_ALL=de_DE.UTF-8 pick up the correct UI.
- panel: execute_action desensitizes button_box on entry and re-enables
  it on error paths, so double-click or keyboard repeat cannot fire the
  same power action twice.
- config: accept_wallpaper helper applies an extension allowlist (jpg,
  jpeg, png, webp) plus symlink rejection and a 10 MB size cap, applied
  to both the user-configured path and the Moonarch ecosystem fallback.
  Bounds worst-case decode latency and narrows the gdk-pixbuf parser
  attack surface.
2026-04-24 13:49:48 +02:00
15 changed files with 433 additions and 111 deletions
+11 -11
View File
@@ -1,22 +1,22 @@
# ABOUTME: Updates pkgver in moonarch-pkgbuilds after a push to main.
# ABOUTME: Ensures paru detects new versions of this package.
# ABOUTME: Updates pkgver in moonarch-pkgbuilds when a new moonset tag is pushed.
# ABOUTME: Reads the latest version tag and bumps the PKGBUILD + .SRCINFO.
name: Update PKGBUILD version
on:
push:
branches:
- main
tags:
- 'v*'
jobs:
update-pkgver:
runs-on: moonarch
steps:
- name: Checkout source repo
- name: Determine pkgver from latest tag
run: |
git clone --bare http://gitea:3000/nevaforget/moonset.git source.git
cd source.git
PKGVER=$(git describe --long --tags | sed 's/^v//;s/-/.r/;s/-/./')
PKGVER=$(git describe --tags --abbrev=0 | sed 's/^v//')
echo "New pkgver: $PKGVER"
echo "$PKGVER" > /tmp/pkgver
@@ -26,18 +26,18 @@ jobs:
git clone http://gitea:3000/nevaforget/moonarch-pkgbuilds.git pkgbuilds
cd pkgbuilds
OLD_VER=$(grep '^pkgver=' moonset-git/PKGBUILD | cut -d= -f2)
OLD_VER=$(grep '^pkgver=' moonset/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/" moonset-git/PKGBUILD
sed -i "s/^\tpkgver = .*/\tpkgver = $PKGVER/" moonset-git/.SRCINFO
sed -i "s/^pkgver=.*/pkgver=$PKGVER/" moonset/PKGBUILD
sed -i "s/^\tpkgver = .*/\tpkgver = $PKGVER/" moonset/.SRCINFO
echo "Updated pkgver: $OLD_VER → $PKGVER"
git config user.name "pkgver-bot"
git config user.email "gitea@moonarch.de"
git add moonset-git/PKGBUILD moonset-git/.SRCINFO
git commit -m "chore(moonset-git): bump pkgver to $PKGVER"
git add moonset/PKGBUILD moonset/.SRCINFO
git commit -m "chore(moonset): bump pkgver to $PKGVER"
git -c http.extraHeader="Authorization: token ${{ secrets.PKGBUILD_TOKEN }}" push
+66
View File
@@ -3,6 +3,72 @@
All notable changes to this project will be documented in this file.
Format based on [Keep a Changelog](https://keepachangelog.com/).
## [0.9.1] - 2026-06-17
### Fixed
- Wallpaper fallback docs (README, example config) referenced a removed "bundled package wallpaper" tier; corrected to two-tier (config → moonarch default → CSS background)
### Added
- Tests for avatar/wallpaper rejection paths (AccountsService symlink, wallpaper symlink/extension/size)
## [0.9.0] - 2026-06-17
### Changed
- Logout is no longer Niri-locked — default terminates the logind session via `loginctl terminate-session $XDG_SESSION_ID` (compositor-agnostic)
### Added
- `logout_command` config key — override the logout command (e.g. `niri msg action quit`) for non-logind or compositor-specific setups
## [0.8.5] - 2026-04-24
### Changed
- Require `MOONSET_DEBUG=1` (not mere presence of the variable) to raise log verbosity, so path information is not written to the journal by accident
### Fixed
- Warn when the home directory cannot be resolved instead of silently searching for `.face` in the current working directory
### Removed
- Drop the unused `uid` field from `users::User` and its obsolete `u32::MAX` fallback sentinel
## [0.8.4] - 2026-04-24
### Security
- Restrict wallpaper paths to an image-extension allowlist (jpg, jpeg, png, webp), reject symlinks, and cap file size at 10 MB — narrows the gdk-pixbuf parser attack surface
### Fixed
- Free the `spawn_blocking` slot on every `wait()` exit path (RAII guard) so a failed `wait()` no longer pins a thread for the full 30 s timeout; distinguish the timeout's own SIGKILL from an external OOM-kill
- Honor POSIX locale precedence (`LC_ALL``LC_MESSAGES``LANG`) before `/etc/locale.conf`, so an English install with `LC_ALL=de_DE.UTF-8` shows the German UI
- Disable the action buttons while an action runs, so a double-click or key repeat cannot trigger the same power action twice
## [0.8.3] - 2026-04-24
### Fixed
- Discard child stdout in `run_command` to remove a latent pipe-deadlock risk when a command writes more than one OS pipe buffer (~64 KB); stderr is still captured for error reporting
## [0.8.2] - 2026-04-06
### Fixed
- Restore keyboard focus to the action buttons after cancelling a confirmation — under layer-shell exclusive keyboard mode the menu otherwise became keyboard-unreachable
## [0.8.1] - 2026-03-31
### Changed
- Optimize the release profile (LTO, single codegen unit, symbol stripping)
- Compress the bundled GResource assets (CSS, SVG)
- Inherit moonlock's stderr so its errors surface in the journal instead of being discarded
## [0.8.0] - 2026-03-30
### Changed
+17 -45
View File
@@ -1,57 +1,29 @@
# Moonset
## Projekt
Wayland session power menu, part of the Moonarch ecosystem.
Keybind-invoked overlay with 5 actions: Lock, Logout, Hibernate, Reboot, Shutdown.
Moonset ist ein Wayland Session Power Menu, gebaut mit Rust + gtk4-rs + gtk4-layer-shell.
Teil des Moonarch-Ökosystems. Per Keybind aufrufbares Overlay mit 5 Aktionen:
Lock, Logout, Hibernate, Reboot, Shutdown.
Stack: Rust / gtk4-rs / gtk4-layer-shell (OVERLAY layer). Versions live in `Cargo.toml`.
## Tech-Stack
- Rust (Edition 2024), gtk4-rs 0.11, glib 0.22
- gtk4-layer-shell 0.8 für Wayland Layer Shell (OVERLAY Layer)
- `cargo test` für Unit-Tests
## Projektstruktur
- `src/` — Rust-Quellcode (main.rs, power.rs, i18n.rs, config.rs, users.rs, panel.rs)
- `resources/` — GResource-Assets (style.css, default-avatar.svg)
- `config/` — Beispiel-Konfigurationsdateien
## Kommandos
## Commands
```bash
# Tests ausführen
cargo test
# Release-Build
cargo build --release
# Power-Menu starten (in Niri-Session)
LD_PRELOAD=/usr/lib/libgtk4-layer-shell.so ./target/release/moonset
cargo test # unit tests
cargo build --release # release build
LD_PRELOAD=/usr/lib/libgtk4-layer-shell.so ./target/release/moonset # run (in Niri)
```
## Architektur
## Source (`src/`)
- `main.rs`Entry Point, GTK App, Layer Shell Setup, Multi-Monitor, systemd-Journal-Logging, Debug-Level per `MOONSET_DEBUG` Env-Var, zentrale `GRESOURCE_PREFIX`-Konstante
- `power.rs` — 5 Power-Action-Wrapper mit absoluten Pfaden und 30s Timeout (lock, logout, hibernate, reboot, shutdown)
- `i18n.rs`Locale-Erkennung und String-Tabellen (DE/EN)
- `config.rs`TOML-Config + Wallpaper-Fallback
- `panel.rs`GTK4 UI (Action-Buttons, Inline-Confirmation, WallpaperWindow)
- `users.rs`User-Erkennung, Avatar-Loading (AccountsService, ~/.face, GResource-Fallback)
- `resources/style.css` — GTK-Theme-Colors für Konsistenz mit dem aktiven Desktop-Theme
- `main.rs`entry point, GTK app, Layer Shell, multi-monitor, journal logging (`MOONSET_DEBUG`), `GRESOURCE_PREFIX`
- `power.rs` — 5 power-action wrappers (absolute paths, 30s timeout)
- `panel.rs`GTK4 UI (action buttons, inline confirmation, WallpaperWindow)
- `users.rs`user detection, avatar loading (AccountsService, ~/.face, GResource fallback)
- `config.rs`TOML config + wallpaper fallback
- `i18n.rs`locale detection, DE/EN string tables
## Design Decisions
`resources/` holds the GResource bundle (style.css, default-avatar.svg); `config/` holds example configs.
Siehe `DECISIONS.md` für das vollständige Entscheidungsprotokoll.
## Decisions
Kurzfassung der wichtigsten Entscheidungen:
- **OVERLAY statt TOP Layer**: Waybar liegt auf TOP, moonset muss darüber
- **Niri-spezifischer Logout** (`niri msg action quit`): Moonarch setzt fest auf Niri
- **Einmal-Start per Keybind**: Kein Daemon, GTK `application_id` verhindert Doppelstart
- **System-Icons**: Adwaita/Catppuccin liefern alle benötigten symbolischen Icons
- **Lock ohne Confirmation**: Lock ist sofort reversibel, braucht kein Confirm
- **Absolute Pfade für Binaries**: `/usr/bin/systemctl` etc. statt relativer Pfade (Security)
- **GResource-Bundle**: CSS und Default-Avatar sind in die Binary kompiliert (Wallpaper kommt vom Dateisystem)
- **Async Power Actions**: `glib::spawn_future_local` + `gio::spawn_blocking` mit 30s Timeout
- **Journal-Logging**: `systemd-journal-logger` statt File-Logging — `journalctl -t moonset`, Debug-Level per `MOONSET_DEBUG` Env-Var
See `DECISIONS.md` for the full decision log (layer choice, Niri logout, async power actions, journal logging, …).
Generated
+1 -1
View File
@@ -616,7 +616,7 @@ dependencies = [
[[package]]
name = "moonset"
version = "0.8.3"
version = "0.9.1"
dependencies = [
"dirs",
"gdk-pixbuf",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "moonset"
version = "0.8.3"
version = "0.9.1"
edition = "2024"
description = "Wayland session power menu with GTK4 and Layer Shell"
license = "MIT"
+22
View File
@@ -2,6 +2,20 @@
Architectural and design decisions for Moonset, in reverse chronological order.
## 2026-04-24 Audit LOW fixes: dead uid field, home_dir warn, clippy sweep, debug value (v0.8.5)
- **Who**: ClaudeCode, Dom
- **Why**: Five LOW findings cleared in one pass. (1) `User::uid` was populated from `getuid()` but never read — a compiler `dead_code` warning for a field on the public API. (2) Falling back to a synthetic user when `get_current_user()` returned None used `uid: u32::MAX`, an undocumented sentinel that became moot once uid was removed. (3) `dirs::home_dir().unwrap_or_default()` silently yielded `PathBuf::new()` on failure; avatars would then look for `.face` in the current working directory. (4) `cargo clippy` flagged three suggestions (two collapsible `if`, one redundant closure) that had crept in. (5) `MOONSET_DEBUG` promoted log verbosity on mere presence, leaking path information into the journal.
- **Tradeoffs**: Dropping `uid` from `User` is a minor API break for any internal caller expecting the field — none existed. The synthetic fallback now surfaces `log::warn!` when home resolution fails, which should be rare outside of pathological sandbox environments.
- **How**: (1) Remove `pub uid: u32` from `User` and the `uid: uid.as_raw()` assignment in `get_current_user`. (2) Panel fallback drops the `uid` field entirely. (3) `dirs::home_dir().unwrap_or_else(|| { log::warn!(...); PathBuf::new() })`. (4) `cargo clippy --fix` for the two collapsible ifs, manual collapse of `if-let` + `&&` chain, redundant closure replaced with the function itself. (5) `MOONSET_DEBUG` now requires the literal value `"1"` to escalate to Debug.
## 2026-04-24 Audit MEDIUM fixes: timeout guard, POSIX locale, button desensitize, wallpaper allowlist (v0.8.4)
- **Who**: ClaudeCode, Dom
- **Why**: Five MEDIUM findings: (1) `run_command`'s timeout thread leaked a 30 s gio::spawn_blocking slot if `child.wait()` errored, because `done.store(true)` ran after the `?`. (2) Timeout detection compared `status.signal() == Some(9)` — a hardcoded signal number that also misclassifies OOM-killer SIGKILL as our timeout. (3) `execute_action` never desensitized the button_box, so a double-click or accidental keyboard repeat fired the action twice. (4) `detect_locale` read only `LANG`, ignoring POSIX priority order (`LC_ALL` > `LC_MESSAGES` > `LANG`) — a common dual-language setup picked the wrong UI language. (5) The wallpaper path was passed to gdk-pixbuf without extension or size restriction, widening the image-parser attack surface and allowing unbounded decode latency.
- **Tradeoffs**: The extension allowlist (`jpg`, `jpeg`, `png`, `webp`) rejects exotic formats users might have used before. The 10 MB size cap rejects uncompressed/high-quality 4K wallpapers; acceptable for a power menu. Memory ordering on the `done` flag is now `Release`/`Acquire` instead of `Relaxed` — no runtime cost but correct across threads.
- **How**: (1) RAII `DoneGuard` struct sets `done.store(true, Release)` in its `Drop`, so the flag fires on every function exit path. A second `timed_out` AtomicBool distinguishes our SIGKILL from an external one. (2) Replace `Some(9)` with the `timed_out` flag check. (3) `execute_action` now takes `button_box: &gtk::Box`, calls `set_sensitive(false)` on entry and re-enables it on error paths; success paths that quit skip the re-enable. All call sites updated. (4) `detect_locale` reads `LC_ALL`, `LC_MESSAGES`, `LANG` in order, picking the first non-empty value before falling back to `/etc/locale.conf`. (5) `accept_wallpaper` helper applies extension allowlist + symlink rejection + `MAX_WALLPAPER_FILE_SIZE = 10 MB`, and is called for both config-path and Moonarch fallback.
## 2026-04-24 Audit fix: avoid latent stdout pipe deadlock in run_command (v0.8.3)
- **Who**: ClaudeCode, Dom
@@ -92,3 +106,11 @@ Architectural and design decisions for Moonset, in reverse chronological order.
- **Why**: Moonarch is built exclusively for the Niri compositor. Generic Wayland logout mechanisms don't exist — each compositor has its own.
- **Tradeoffs**: Hard dependency on Niri. If the compositor changes, `power::logout()` must be updated.
- **How**: `Command::new("/usr/bin/niri").args(["msg", "action", "quit"])`.
- **Superseded by** the 2026-06-17 entry below.
## 2026-06-17 Compositor-agnostic logout via `loginctl terminate-session`
- **Who**: ClaudeCode, Dom
- **Why**: Reverses the 2026-03-27 decision. Hardcoding `niri msg action quit` made logout the only compositor-locked action (lock/hibernate/reboot/shutdown were already agnostic via moonlock/systemctl). There is no Wayland protocol for "end session", but systemd-logind sits one layer below and offers a portable mechanism.
- **Tradeoffs**: `loginctl terminate-session` SIGTERMs all session processes rather than letting the compositor quit itself — functionally equivalent for logout. Requires a logind session (verified: greetd's PAM chain loads `pam_systemd`, which sets `XDG_SESSION_ID`). Niri's own quit command remains available via the override.
- **How**: `power::logout()` resolves a command — TOML `logout_command` override (space-separated) if set, else `/usr/bin/loginctl terminate-session $XDG_SESSION_ID`. Errors with a hint to set `logout_command` when no session id is present.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Dominik Kressler
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+16 -1
View File
@@ -15,6 +15,13 @@ A fullscreen overlay triggered by keybind with 5 actions:
- DE/EN localization
- Configurable wallpaper (TOML)
## Requirements
- A Wayland compositor with `wlr-layer-shell` (Moonarch targets **Niri**)
- A systemd-logind session for the default logout (`loginctl terminate-session`); other setups can override via `logout_command`
- Runtime: `gtk4`, `gtk4-layer-shell`
- Build: `cargo`, `git`
## Installation
```bash
@@ -47,9 +54,13 @@ Config file: `~/.config/moonset/moonset.toml` or `/etc/moonset/moonset.toml`
```toml
# Path to background image (optional)
background_path = "/usr/share/moonarch/wallpaper.jpg"
# Logout command override (optional)
# Default: loginctl terminate-session $XDG_SESSION_ID
logout_command = "niri msg action quit"
```
Wallpaper fallback: config → `/usr/share/moonarch/wallpaper.jpg`bundled package wallpaper
Wallpaper fallback: config → `/usr/share/moonarch/wallpaper.jpg`CSS background (no image)
## Development
@@ -67,3 +78,7 @@ cargo build --release
- **moongreet** — greetd greeter for Wayland
- **moonlock** — Wayland lockscreen
- **moonset** — Session power menu
## License
MIT
+7 -1
View File
@@ -2,5 +2,11 @@
# Config file: ~/.config/moonset/moonset.toml or /etc/moonset/moonset.toml
# Path to background image (optional)
# Fallback order: config → /usr/share/moonarch/wallpaper.jpg → bundled package wallpaper
# Fallback order: config → /usr/share/moonarch/wallpaper.jpg → CSS background (no image)
# background_path = "/usr/share/moonarch/wallpaper.jpg"
# Logout command override (optional, space-separated program + args)
# Default: terminate the logind session via `loginctl terminate-session $XDG_SESSION_ID`
# (compositor-agnostic). Set this only for non-logind setups or to delegate
# logout to the compositor.
# logout_command = "niri msg action quit"
+96 -6
View File
@@ -21,6 +21,9 @@ fn default_config_paths() -> Vec<PathBuf> {
pub struct Config {
pub background_path: Option<String>,
pub background_blur: Option<f32>,
/// Override for the logout command (space-separated program + args).
/// When unset, logout terminates the logind session via `loginctl`.
pub logout_command: Option<String>,
}
/// Load config from TOML files. Later paths override earlier ones.
@@ -37,6 +40,9 @@ pub fn load_config(config_paths: Option<&[PathBuf]>) -> Config {
if parsed.background_path.is_some() {
merged.background_path = parsed.background_path;
}
if parsed.logout_command.is_some() {
merged.logout_command = parsed.logout_command;
}
// Validate blur per source — invalid values preserve the previous default
if parsed.background_blur.is_some_and(|b| b.is_finite() && (0.0..=200.0).contains(&b)) {
merged.background_blur = parsed.background_blur;
@@ -62,21 +68,56 @@ pub fn resolve_background_path(config: &Config) -> Option<PathBuf> {
resolve_background_path_with(config, Path::new(MOONARCH_WALLPAPER))
}
/// Wallpapers are passed to gdk-pixbuf's image loader; restrict to common image
/// extensions to reduce the parser-attack surface for user-controlled paths.
const ALLOWED_BG_EXT: &[&str] = &["jpg", "jpeg", "png", "webp"];
/// Bound wallpaper decode latency (10 MB covers typical 4K JPEGs at Q95).
const MAX_WALLPAPER_FILE_SIZE: u64 = 10 * 1024 * 1024;
fn is_allowed_wallpaper(path: &Path) -> bool {
match path.extension().and_then(|e| e.to_str()) {
Some(ext) => ALLOWED_BG_EXT.iter().any(|a| a.eq_ignore_ascii_case(ext)),
None => false,
}
}
fn accept_wallpaper(path: &Path) -> bool {
if !is_allowed_wallpaper(path) {
log::warn!("Wallpaper rejected (extension not in allowlist): {}", path.display());
return false;
}
match path.symlink_metadata() {
Ok(meta) if meta.file_type().is_symlink() => {
log::warn!("Wallpaper rejected (symlink): {}", path.display());
false
}
Ok(meta) if !meta.is_file() => false,
Ok(meta) if meta.len() > MAX_WALLPAPER_FILE_SIZE => {
log::warn!(
"Wallpaper rejected ({} bytes > {} limit): {}",
meta.len(), MAX_WALLPAPER_FILE_SIZE, path.display()
);
false
}
Ok(_) => true,
Err(_) => false,
}
}
/// Resolve with configurable moonarch wallpaper path (for testing).
pub fn resolve_background_path_with(config: &Config, moonarch_wallpaper: &Path) -> Option<PathBuf> {
// User-configured path — reject symlinks to prevent path traversal
// User-configured path — reject symlinks, non-image extensions, and oversized files
if let Some(ref bg) = config.background_path {
let path = PathBuf::from(bg);
if let Ok(meta) = path.symlink_metadata() {
if meta.is_file() && !meta.file_type().is_symlink() {
if accept_wallpaper(&path) {
log::debug!("Wallpaper source: config ({})", path.display());
return Some(path);
}
}
}
// Moonarch ecosystem default
if moonarch_wallpaper.is_file() {
// Moonarch ecosystem default — apply the same checks for consistency
if accept_wallpaper(moonarch_wallpaper) {
log::debug!("Wallpaper source: moonarch default ({})", moonarch_wallpaper.display());
return Some(moonarch_wallpaper.to_path_buf());
}
@@ -94,6 +135,17 @@ mod tests {
let config = Config::default();
assert!(config.background_path.is_none());
assert!(config.background_blur.is_none());
assert!(config.logout_command.is_none());
}
#[test]
fn load_config_reads_logout_command() {
let dir = tempfile::tempdir().unwrap();
let conf = dir.path().join("moonset.toml");
fs::write(&conf, "logout_command = \"niri msg action quit\"\n").unwrap();
let paths = vec![conf];
let config = load_config(Some(&paths));
assert_eq!(config.logout_command.as_deref(), Some("niri msg action quit"));
}
#[test]
@@ -198,6 +250,44 @@ mod tests {
assert_eq!(result, None);
}
#[test]
fn resolve_rejects_symlinked_config_wallpaper() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("real.jpg");
fs::write(&target, "fake").unwrap();
let link = dir.path().join("link.jpg");
std::os::unix::fs::symlink(&target, &link).unwrap();
let config = Config {
background_path: Some(link.to_str().unwrap().to_string()),
..Config::default()
};
assert_eq!(resolve_background_path_with(&config, Path::new("/nonexistent")), None);
}
#[test]
fn resolve_rejects_disallowed_extension() {
let dir = tempfile::tempdir().unwrap();
let wp = dir.path().join("wallpaper.bmp");
fs::write(&wp, "fake").unwrap();
let config = Config {
background_path: Some(wp.to_str().unwrap().to_string()),
..Config::default()
};
assert_eq!(resolve_background_path_with(&config, Path::new("/nonexistent")), None);
}
#[test]
fn resolve_rejects_oversized_wallpaper() {
let dir = tempfile::tempdir().unwrap();
let wp = dir.path().join("huge.jpg");
fs::write(&wp, vec![0u8; (MAX_WALLPAPER_FILE_SIZE + 1) as usize]).unwrap();
let config = Config {
background_path: Some(wp.to_str().unwrap().to_string()),
..Config::default()
};
assert_eq!(resolve_background_path_with(&config, Path::new("/nonexistent")), None);
}
#[test]
fn load_config_ignores_invalid_toml_syntax() {
let dir = tempfile::tempdir().unwrap();
+10 -3
View File
@@ -110,15 +110,22 @@ fn read_lang_from_conf(path: &Path) -> Option<String> {
None
}
/// Determine the system language from LANG env var or /etc/locale.conf.
/// Determine the system language from POSIX locale env vars or /etc/locale.conf.
/// Checks LC_ALL, LC_MESSAGES, LANG in POSIX priority order (LC_ALL overrides
/// everything; LC_MESSAGES overrides LANG for text categories).
pub fn detect_locale() -> String {
detect_locale_with(env::var("LANG").ok().as_deref(), Path::new(DEFAULT_LOCALE_CONF))
let env_val = env::var("LC_ALL")
.ok()
.filter(|s| !s.is_empty())
.or_else(|| env::var("LC_MESSAGES").ok().filter(|s| !s.is_empty()))
.or_else(|| env::var("LANG").ok().filter(|s| !s.is_empty()));
detect_locale_with(env_val.as_deref(), Path::new(DEFAULT_LOCALE_CONF))
}
/// Determine locale with configurable inputs (for testing).
pub fn detect_locale_with(env_lang: Option<&str>, locale_conf_path: &Path) -> String {
let (raw, source) = if let Some(val) = env_lang.filter(|s| !s.is_empty()) {
(Some(val.to_string()), "LANG env")
(Some(val.to_string()), "env")
} else if let Some(val) = read_lang_from_conf(locale_conf_path) {
(Some(val), "locale.conf")
} else {
+6 -4
View File
@@ -88,10 +88,12 @@ fn setup_logging() {
eprintln!("Failed to create journal logger: {e}");
}
}
let level = if std::env::var("MOONSET_DEBUG").is_ok() {
log::LevelFilter::Debug
} else {
log::LevelFilter::Info
// Require MOONSET_DEBUG=1 to raise verbosity so mere presence (empty
// value in a session script) cannot escalate journal noise with path
// information an attacker could use.
let level = match std::env::var("MOONSET_DEBUG").ok().as_deref() {
Some("1") => log::LevelFilter::Debug,
_ => log::LevelFilter::Info,
};
log::set_max_level(level);
}
+25 -6
View File
@@ -7,7 +7,7 @@ use glib::clone;
use gtk4::prelude::*;
use gtk4::{self as gtk, gio};
use std::cell::RefCell;
use std::path::Path;
use std::path::{Path, PathBuf};
use std::rc::Rc;
use std::time::Duration;
@@ -208,11 +208,16 @@ pub fn create_panel_window(texture: Option<&gdk::Texture>, blur_radius: Option<f
window.add_css_class("panel");
let strings = load_strings(None);
let user = users::get_current_user().unwrap_or_else(|| users::User {
let user = users::get_current_user().unwrap_or_else(|| {
let home = dirs::home_dir().unwrap_or_else(|| {
log::warn!("Could not resolve HOME — using an empty path");
PathBuf::new()
});
users::User {
username: "user".to_string(),
display_name: "User".to_string(),
home: dirs::home_dir().unwrap_or_default(),
uid: u32::MAX,
home,
}
});
log::debug!("User: {} ({})", user.display_name, user.username);
@@ -445,7 +450,7 @@ fn on_action_clicked(
error_label.set_visible(false);
if !action_def.needs_confirm {
execute_action(action_def, strings, app, confirm_area, confirm_box, error_label);
execute_action(action_def, strings, app, confirm_area, confirm_box, error_label, button_box);
return;
}
@@ -488,6 +493,8 @@ fn show_confirm(
confirm_box,
#[weak]
error_label,
#[weak]
button_box,
move |_| {
execute_action(
&action_def_clone,
@@ -496,6 +503,7 @@ fn show_confirm(
&confirm_area,
&confirm_box,
&error_label,
&button_box,
);
}
));
@@ -543,6 +551,7 @@ fn execute_action(
confirm_area: &gtk::Box,
confirm_box: &Rc<RefCell<Option<gtk::Box>>>,
error_label: &gtk::Label,
button_box: &gtk::Box,
) {
dismiss_confirm(confirm_area, confirm_box);
log::debug!("Executing power action: {}", action_def.name);
@@ -552,6 +561,10 @@ fn execute_action(
let quit_after = action_def.quit_after;
let error_message = (action_def.error_attr)(strings).to_string();
// Desensitize buttons so a double-click or accidental keyboard repeat
// cannot fire the same action twice while it is in flight.
button_box.set_sensitive(false);
// Use glib::spawn_future_local + gio::spawn_blocking to avoid Send issues
// with GTK objects. The blocking closure runs on a thread pool, the result
// is handled back on the main thread.
@@ -560,24 +573,30 @@ fn execute_action(
app,
#[weak]
error_label,
#[weak]
button_box,
async move {
let result = gio::spawn_blocking(move || action_fn()).await;
let result = gio::spawn_blocking(action_fn).await;
match result {
Ok(Ok(())) => {
if quit_after {
fade_out_and_quit(&app);
} else {
button_box.set_sensitive(true);
}
}
Ok(Err(e)) => {
log::error!("Power action '{}' failed: {}", action_name, e);
error_label.set_text(&error_message);
error_label.set_visible(true);
button_box.set_sensitive(true);
}
Err(_) => {
log::error!("Power action '{}' panicked", action_name);
error_label.set_text(&error_message);
error_label.set_visible(true);
button_box.set_sensitive(true);
}
}
}
+105 -15
View File
@@ -52,44 +52,50 @@ fn run_command(action: &'static str, program: &str, args: &[&str]) -> Result<(),
let child_pid = nix::unistd::Pid::from_raw(child.id() as i32);
let done = Arc::new(AtomicBool::new(false));
let timed_out = Arc::new(AtomicBool::new(false));
let done_clone = done.clone();
let timed_out_clone = timed_out.clone();
let timeout_thread = std::thread::spawn(move || {
// Sleep in short intervals so we can exit early when the child finishes
let _timeout_thread = std::thread::spawn(move || {
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) {
if done_clone.load(Ordering::Acquire) {
return;
}
elapsed += interval;
}
// Record that we fired the kill so we don't misclassify an external
// SIGKILL (OOM killer, kill -9) as our timeout.
timed_out_clone.store(true, Ordering::Release);
// ESRCH if the process already exited — harmless
let _ = nix::sys::signal::kill(child_pid, nix::sys::signal::Signal::SIGKILL);
});
// Drop guard ensures the timeout thread sees done=true even if child.wait()
// errors out — otherwise the thread sleeps its full 30 s holding a slot in
// the gio::spawn_blocking pool.
struct DoneGuard(Arc<AtomicBool>);
impl Drop for DoneGuard {
fn drop(&mut self) {
self.0.store(true, Ordering::Release);
}
}
let _done_guard = DoneGuard(done);
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 {
// Check if killed by our timeout (SIGKILL = signal 9)
#[cfg(unix)]
{
use std::os::unix::process::ExitStatusExt;
if status.signal() == Some(9) {
if timed_out.load(Ordering::Acquire) {
return Err(PowerError::Timeout { action });
}
}
let mut stderr_buf = String::new();
if let Some(mut stderr) = child.stderr.take() {
let _ = stderr.read_to_string(&mut stderr_buf);
@@ -119,9 +125,50 @@ pub fn lock() -> Result<(), PowerError> {
Ok(())
}
/// Quit the Niri compositor (logout).
/// Resolve the logout command into program + arguments.
///
/// Priority: a user-configured `logout_command` (space-separated) overrides
/// everything. Otherwise the logind session is terminated via
/// `loginctl terminate-session <id>` — compositor-agnostic, since every
/// pam_systemd session sets `XDG_SESSION_ID`.
fn resolve_logout_command(
override_cmd: Option<&str>,
session_id: Option<&str>,
) -> Result<Vec<String>, PowerError> {
if let Some(cmd) = override_cmd {
let parts: Vec<String> = cmd.split_whitespace().map(str::to_string).collect();
if parts.is_empty() {
return Err(PowerError::CommandFailed {
action: "logout",
message: "logout_command is empty".to_string(),
});
}
return Ok(parts);
}
match session_id {
Some(id) if !id.is_empty() => Ok(vec![
"/usr/bin/loginctl".to_string(),
"terminate-session".to_string(),
id.to_string(),
]),
_ => Err(PowerError::CommandFailed {
action: "logout",
message: "XDG_SESSION_ID unset; set logout_command in moonset.toml".to_string(),
}),
}
}
/// End the session (logout).
///
/// Terminates the logind session by default; honours a `logout_command`
/// override from the config for non-logind or compositor-specific setups.
pub fn logout() -> Result<(), PowerError> {
run_command("logout", "/usr/bin/niri", &["msg", "action", "quit"])
let config = crate::config::load_config(None);
let session_id = std::env::var("XDG_SESSION_ID").ok();
let parts = resolve_logout_command(config.logout_command.as_deref(), session_id.as_deref())?;
let args: Vec<&str> = parts[1..].iter().map(String::as_str).collect();
run_command("logout", &parts[0], &args)
}
/// Hibernate the system via systemctl.
@@ -186,4 +233,47 @@ mod tests {
let result = run_command("test", "echo", &["hello", "world"]);
assert!(result.is_ok());
}
#[test]
fn logout_default_uses_loginctl_with_session_id() {
let parts = resolve_logout_command(None, Some("3")).unwrap();
assert_eq!(parts, vec!["/usr/bin/loginctl", "terminate-session", "3"]);
}
#[test]
fn logout_override_takes_precedence() {
let parts = resolve_logout_command(Some("niri msg action quit"), Some("3")).unwrap();
assert_eq!(parts, vec!["niri", "msg", "action", "quit"]);
}
#[test]
fn logout_override_ignores_session_id() {
// An override resolves even without a session id.
let parts = resolve_logout_command(Some("/usr/bin/swaymsg exit"), None).unwrap();
assert_eq!(parts, vec!["/usr/bin/swaymsg", "exit"]);
}
#[test]
fn logout_default_errors_without_session_id() {
assert!(matches!(
resolve_logout_command(None, None),
Err(PowerError::CommandFailed { action: "logout", .. })
));
}
#[test]
fn logout_default_errors_on_empty_session_id() {
assert!(matches!(
resolve_logout_command(None, Some("")),
Err(PowerError::CommandFailed { action: "logout", .. })
));
}
#[test]
fn logout_override_errors_when_blank() {
assert!(matches!(
resolve_logout_command(Some(" "), Some("3")),
Err(PowerError::CommandFailed { action: "logout", .. })
));
}
}
+17 -5
View File
@@ -12,7 +12,6 @@ pub struct User {
pub username: String,
pub display_name: String,
pub home: PathBuf,
pub uid: u32,
}
/// Get the currently logged-in user's info from the system.
@@ -37,7 +36,6 @@ pub fn get_current_user() -> Option<User> {
username: nix_user.name,
display_name,
home: nix_user.dir,
uid: uid.as_raw(),
})
}
@@ -65,8 +63,9 @@ pub fn get_avatar_path_with(
}
// AccountsService icon fallback
if let Some(name) = username {
if accountsservice_dir.exists() {
if let Some(name) = username
&& accountsservice_dir.exists()
{
let icon = accountsservice_dir.join(name);
if let Ok(meta) = icon.symlink_metadata() {
if meta.file_type().is_symlink() {
@@ -77,7 +76,6 @@ pub fn get_avatar_path_with(
}
}
}
}
None
}
@@ -147,6 +145,20 @@ mod tests {
assert!(path.is_none());
}
#[test]
fn rejects_symlink_accountsservice_icon() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("secret");
fs::write(&target, "secret content").unwrap();
let icons_dir = dir.path().join("icons");
fs::create_dir(&icons_dir).unwrap();
let icon = icons_dir.join("testuser");
std::os::unix::fs::symlink(&target, &icon).unwrap();
// No ~/.face, so resolution falls through to the AccountsService branch
let path = get_avatar_path_with(dir.path(), Some("testuser"), &icons_dir);
assert!(path.is_none());
}
#[test]
fn returns_none_when_no_avatar() {
let dir = tempfile::tempdir().unwrap();