5 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
14 changed files with 338 additions and 85 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.4"
version = "0.9.1"
dependencies = [
"dirs",
"gdk-pixbuf",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "moonset"
version = "0.8.4"
version = "0.9.1"
edition = "2024"
description = "Wayland session power menu with GTK4 and Layer Shell"
license = "MIT"
+15
View File
@@ -2,6 +2,13 @@
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
@@ -99,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"
+55
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;
@@ -129,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]
@@ -233,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();
+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);
}
+12 -7
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 {
username: "user".to_string(),
display_name: "User".to_string(),
home: dirs::home_dir().unwrap_or_default(),
uid: u32::MAX,
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,
}
});
log::debug!("User: {} ({})", user.display_name, user.username);
@@ -571,7 +576,7 @@ fn execute_action(
#[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(())) => {
+86 -2
View File
@@ -125,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.
@@ -192,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", .. })
));
}
}
+24 -12
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,16 +63,16 @@ pub fn get_avatar_path_with(
}
// AccountsService icon fallback
if let Some(name) = username {
if accountsservice_dir.exists() {
let icon = accountsservice_dir.join(name);
if let Ok(meta) = icon.symlink_metadata() {
if meta.file_type().is_symlink() {
log::warn!("Rejecting symlink avatar: {}", icon.display());
} else if meta.is_file() {
log::debug!("Avatar: using AccountsService icon ({})", icon.display());
return Some(icon);
}
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() {
log::warn!("Rejecting symlink avatar: {}", icon.display());
} else if meta.is_file() {
log::debug!("Avatar: using AccountsService icon ({})", icon.display());
return Some(icon);
}
}
}
@@ -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();