Compare commits

...

9 Commits

Author SHA1 Message Date
nevaforget cab1997dff fix: GTK-Theme-Validierung entfernt — GTK löst Theme-Namen selbst auf
Die Regex VALID_THEME_NAME blockierte Theme-Namen mit '+' (z.B.
catppuccin-mocha-lavender-standard+default). Da GTK den Theme-Namen
intern über Standardverzeichnisse auflöst, ist eigene Validierung
unnötig und kontraproduktiv.
2026-03-26 15:37:02 +01:00
nevaforget 3dfa596f9a fix: greetd-Session nach Auth-Fehler sauber canceln
Nach fehlgeschlagenem Login (falsches Passwort) wurde die greetd-Session
nicht gecancelt — beim nächsten Versuch kam "a session is already being
configured". Jetzt wird cancel_session gesendet nach Auth-Fehler, und
bei create_session-Fehler wird einmal cancel + retry versucht.

Außerdem: GTK-Theme-Name und PKGBUILD-pkgver aktualisiert.
2026-03-26 15:26:12 +01:00
nevaforget 357d2459cf fix: IPC byte order, globales GTK-Theme, Session-Vorauswahl
- ipc.py: !I (Big-Endian) → =I (Native Byte Order) für greetd-Protokoll
- Per-User GTK-Theme entfernt, stattdessen globales Theme aus moongreet.toml
- Last-Session pro User in /var/cache/moongreet/last-session/ speichern/laden
- PKGBUILD und install-Hook für last-session-Cache erweitert
2026-03-26 14:51:23 +01:00
nevaforget ba4f30f254 fix: Niri-Greeter-Config mit Retry-Loop gegen offene Session bei Crash
Wenn moongreet crasht bevor Niri bereit ist, blieb eine offene
Niri-Session als greeter-User zurueck. Retry-Loop stellt sicher,
dass niri sich in jedem Fall beendet. Beispiel-Configs korrigiert
(moongreet braucht Niri als Compositor).
2026-03-26 14:29:49 +01:00
nevaforget e37b273913 fix: Display-Null-Check und File-Logging in main.py
Gdk.Display.get_default() kann None zurueckgeben wenn der Compositor
noch nicht bereit ist. Vorher crashte moongreet mit TypeError, ohne
dass der Fehler irgendwo geloggt wurde. Display wird jetzt einmal
geholt, geprueft und an _register_icons/_load_css durchgereicht.
Logging geht nach /var/cache/moongreet/moongreet.log und stderr.
2026-03-26 14:16:38 +01:00
nevaforget ecd89f5b10 Simplify pkgver() to require tags 2026-03-26 14:02:38 +01:00
nevaforget d089fa201c fix: Build-Artefakte aus Repo entfernt, .gitignore ergänzt
makepkg-Artefakte (pkg/src, pkg/pkg, .pkg.tar.zst) waren
versehentlich committed. Entfernt und per .gitignore geschützt.
2026-03-26 13:44:07 +01:00
nevaforget 6400270a50 fix: PKGBUILD compositor-agnostisch, Beispiel-Configs bereinigt
Moongreet ist ein reiner GTK4-Greeter ohne eigenen Compositor.
niri-greeter.kdl entfernt — der User konfiguriert seinen
Compositor selbst (regreet → moongreet tauschen).
2026-03-26 13:38:27 +01:00
nevaforget 10b613b50b fix: PKGBUILD als -git Paket mit automatischer Versionierung
pkgver() generiert Version aus git describe, makepkg -si
aktualisiert automatisch ohne manuelles Version-Bumpen.
2026-03-26 13:30:36 +01:00
15 changed files with 339 additions and 75 deletions
+6
View File
@@ -8,3 +8,9 @@ build/
.pytest_cache/
.pyright/
*.egg
# makepkg build artifacts
pkg/src/
pkg/pkg/
pkg/*.pkg.tar*
pkg/greetd-moongreet/
+3 -1
View File
@@ -5,5 +5,7 @@
vt = 1
[default_session]
command = "moongreet"
# Moongreet braucht einen Wayland-Compositor — niri stellt diesen bereit.
# Siehe niri-greeter.kdl fuer die Compositor-Konfiguration.
command = "niri -c /etc/greetd/niri-greeter.kdl"
user = "greeter"
+2
View File
@@ -4,3 +4,5 @@
[appearance]
# Absolute path to wallpaper image
background = "/usr/share/backgrounds/wallpaper.jpg"
# GTK theme for the greeter UI
gtk-theme = "catppuccin-mocha-lavender-standard+default"
+59
View File
@@ -0,0 +1,59 @@
// ABOUTME: Niri-Konfiguration fuer den Moongreet Login-Greeter.
// ABOUTME: Wird von greetd gestartet — minimale Config ohne Keybinds fuer Sicherheit.
input {
keyboard {
xkb {
layout "de"
}
numlock
}
touchpad {
tap
natural-scroll
}
mouse {
accel-profile "flat"
}
}
cursor {
xcursor-theme "Sweet-cursors"
}
layout {
gaps 0
focus-ring {
off
}
border {
off
}
}
// Moongreet starten und niri beenden, sobald moongreet sich schliesst.
// Retry-Loop stellt sicher, dass niri auch bei fruehen Crashes von moongreet beendet wird.
spawn-sh-at-startup "moongreet; while ! niri msg action quit --skip-confirmation 2>/dev/null; do sleep 0.5; done"
// Greeter-Fenster maximiert darstellen
window-rule {
open-maximized true
}
hotkey-overlay {
skip-at-startup
}
prefer-no-csd
animations {
off
}
binds {
// Keine Keybinds — verhindert Zugriff auf Terminals oder andere Aktionen
}
+15 -7
View File
@@ -1,10 +1,10 @@
# ABOUTME: AUR PKGBUILD for Moongreet — greetd greeter for Wayland.
# ABOUTME: Builds from git source, installs config and cache directory.
# ABOUTME: PKGBUILD for Moongreet — greetd greeter for Wayland.
# ABOUTME: Builds from git source with automatic version detection.
# Maintainer: Dominik Kressler
pkgname=moongreet
pkgver=0.1.0
pkgname=moongreet-git
pkgver=0.1.0.r7.g357d245
pkgrel=1
pkgdesc="A greetd greeter for Wayland, built with Python + GTK4 + gtk4-layer-shell"
arch=('any')
@@ -23,10 +23,17 @@ makedepends=(
'python-installer'
'python-hatchling'
)
provides=('moongreet')
conflicts=('moongreet')
install=moongreet.install
source=("git+${url}.git#tag=v${pkgver}")
source=("git+${url}.git")
sha256sums=('SKIP')
pkgver() {
cd "$srcdir/greetd-moongreet"
git describe --long --tags | sed 's/^v//;s/-/.r/;s/-/./'
}
build() {
cd "$srcdir/greetd-moongreet"
python -m build --wheel --no-isolation
@@ -36,9 +43,10 @@ package() {
cd "$srcdir/greetd-moongreet"
python -m installer --destdir="$pkgdir" dist/*.whl
# Example config
# Greeter config
install -Dm644 config/moongreet.toml "$pkgdir/etc/moongreet/moongreet.toml"
# Cache directory
# Cache directories
install -dm755 "$pkgdir/var/cache/moongreet"
install -dm755 "$pkgdir/var/cache/moongreet/last-session"
}
+5 -6
View File
@@ -1,16 +1,15 @@
# ABOUTME: pacman install hooks for Moongreet.
# ABOUTME: Sets ownership on cache directory for the greeter user.
# ABOUTME: Sets ownership on cache directory and prints setup instructions.
post_install() {
if getent passwd greeter > /dev/null 2>&1; then
chown greeter:greeter /var/cache/moongreet
chown greeter:greeter /var/cache/moongreet/last-session
fi
echo "==> Copy /etc/moongreet/moongreet.toml and adjust the wallpaper path."
echo "==> Configure greetd to use moongreet:"
echo " [default_session]"
echo " command = \"moongreet\""
echo " user = \"greeter\""
echo "==> Moongreet installed."
echo "==> Add moongreet to your greeter compositor command in /etc/greetd/config.toml."
echo "==> Adjust wallpaper: /etc/moongreet/moongreet.toml"
}
post_upgrade() {
+5
View File
@@ -17,6 +17,7 @@ class Config:
"""Greeter configuration loaded from moongreet.toml."""
background: Path | None = None
gtk_theme: str | None = None
def load_config(config_path: Path | None = None) -> Config:
@@ -51,6 +52,10 @@ def load_config(config_path: Path | None = None) -> Config:
bg_path = config_path.parent / bg_path
config.background = bg_path
gtk_theme = appearance.get("gtk-theme")
if gtk_theme:
config.gtk_theme = gtk_theme
return config
+58 -15
View File
@@ -21,13 +21,14 @@ from gi.repository import Gtk, Gdk, GLib, GdkPixbuf
from moongreet.config import load_config, resolve_wallpaper_path
from moongreet.i18n import load_strings, Strings
from moongreet.ipc import create_session, post_auth_response, start_session, cancel_session
from moongreet.users import User, get_users, get_avatar_path, get_user_gtk_theme
from moongreet.users import User, get_users, get_avatar_path
from moongreet.sessions import Session, get_sessions
from moongreet.power import reboot, shutdown
logger = logging.getLogger(__name__)
LAST_USER_PATH = Path("/var/cache/moongreet/last-user")
LAST_SESSION_DIR = Path("/var/cache/moongreet/last-session")
FAILLOCK_MAX_ATTEMPTS = 3
VALID_USERNAME = re.compile(r"^[a-zA-Z0-9_.-]+$")
MAX_USERNAME_LENGTH = 256
@@ -93,6 +94,7 @@ class GreeterWindow(Gtk.ApplicationWindow):
self._failed_attempts: dict[str, int] = {}
self._bg_path = bg_path
self._apply_global_theme()
self._build_ui()
self._setup_keyboard_navigation()
# Defer initial user selection until the window is realized,
@@ -286,26 +288,23 @@ class GreeterWindow(Gtk.ApplicationWindow):
# which works in ZIP wheels too, no exists() check needed
self._set_default_avatar()
# Apply user's GTK theme if available
self._apply_user_theme(user)
# Pre-select last used session for this user
self._select_last_session(user)
# Focus password entry
self._password_entry.grab_focus()
def _apply_user_theme(self, user: User) -> None:
"""Load the user's preferred GTK theme from their settings.ini."""
gtk_config_dir = user.home / ".config" / "gtk-4.0"
theme_name = get_user_gtk_theme(config_dir=gtk_config_dir)
def _apply_global_theme(self) -> None:
"""Apply the GTK theme from moongreet.toml configuration."""
theme_name = self._config.gtk_theme
if not theme_name:
return
settings = Gtk.Settings.get_default()
if settings is None:
return
current = settings.get_property("gtk-theme-name")
if theme_name and current != theme_name:
settings.set_property("gtk-theme-name", theme_name)
elif not theme_name and current:
settings.reset_property("gtk-theme-name")
settings.set_property("gtk-theme-name", theme_name)
def _get_foreground_color(self) -> str:
"""Get the current GTK theme foreground color as a hex string."""
@@ -445,12 +444,15 @@ class GreeterWindow(Gtk.ApplicationWindow):
with self._greetd_sock_lock:
self._greetd_sock = sock
# Step 1: Create session
# Step 1: Create session — if a stale session exists, cancel it and retry
response = create_session(sock, user.username)
if response.get("type") == "error":
GLib.idle_add(self._on_login_error, response, self._strings.auth_failed)
return
cancel_session(sock)
response = create_session(sock, user.username)
if response.get("type") == "error":
GLib.idle_add(self._on_login_error, response, self._strings.auth_failed)
return
# Step 2: Send password if auth message received
if response.get("type") == "auth_message":
@@ -459,6 +461,7 @@ class GreeterWindow(Gtk.ApplicationWindow):
if response.get("type") == "error":
self._failed_attempts[user.username] = self._failed_attempts.get(user.username, 0) + 1
warning = faillock_warning(self._failed_attempts[user.username], self._strings)
cancel_session(sock)
GLib.idle_add(self._on_login_auth_error, response, warning)
return
@@ -479,6 +482,7 @@ class GreeterWindow(Gtk.ApplicationWindow):
if response.get("type") == "success":
self._save_last_user(user.username)
self._save_last_session(user.username, session.name)
self._close_greetd_sock()
GLib.idle_add(self.get_application().quit)
return
@@ -533,6 +537,18 @@ class GreeterWindow(Gtk.ApplicationWindow):
return self._sessions[idx]
return None
def _select_last_session(self, user: User) -> None:
"""Pre-select the last used session for a user in the dropdown."""
if not self._sessions:
return
last_session_name = self._load_last_session(user.username)
if not last_session_name:
return
for i, session in enumerate(self._sessions):
if session.name == last_session_name:
self._session_dropdown.set_selected(i)
return
MAX_GREETD_ERROR_LENGTH = 200
def _show_greetd_error(self, response: dict, fallback: str) -> None:
@@ -585,3 +601,30 @@ class GreeterWindow(Gtk.ApplicationWindow):
LAST_USER_PATH.write_text(username)
except OSError:
pass # Non-critical — cache dir may not be writable
MAX_SESSION_NAME_LENGTH = 256
@staticmethod
def _save_last_session(username: str, session_name: str) -> None:
"""Save the last used session name for a user to cache."""
if not VALID_USERNAME.match(username) or len(username) > MAX_USERNAME_LENGTH:
return
try:
LAST_SESSION_DIR.mkdir(parents=True, exist_ok=True)
(LAST_SESSION_DIR / username).write_text(session_name)
except OSError:
pass # Non-critical — cache dir may not be writable
@staticmethod
def _load_last_session(username: str) -> str | None:
"""Load the last used session name for a user from cache."""
session_file = LAST_SESSION_DIR / username
if not session_file.exists():
return None
try:
name = session_file.read_text().strip()
except OSError:
return None
if not name or len(name) > GreeterWindow.MAX_SESSION_NAME_LENGTH:
return None
return name
+2 -2
View File
@@ -22,14 +22,14 @@ def _recvall(sock: Any, n: int) -> bytes:
def send_message(sock: Any, msg: dict) -> None:
"""Send a length-prefixed JSON message to the greetd socket."""
payload = json.dumps(msg).encode("utf-8")
header = struct.pack("!I", len(payload))
header = struct.pack("=I", len(payload))
sock.sendall(header + payload)
def recv_message(sock: Any) -> dict:
"""Receive a length-prefixed JSON message from the greetd socket."""
header = _recvall(sock, 4)
length = struct.unpack("!I", header)[0]
length = struct.unpack("=I", header)[0]
if length > MAX_PAYLOAD_SIZE:
raise ConnectionError(f"Payload too large: {length} bytes (max {MAX_PAYLOAD_SIZE})")
+43 -7
View File
@@ -4,6 +4,7 @@
import logging
import sys
from importlib.resources import files
from pathlib import Path
import gi
gi.require_version("Gtk", "4.0")
@@ -21,9 +22,38 @@ try:
except (ValueError, ImportError):
HAS_LAYER_SHELL = False
LOG_DIR = Path("/var/cache/moongreet")
LOG_FILE = LOG_DIR / "moongreet.log"
logger = logging.getLogger(__name__)
def _setup_logging() -> None:
"""Configure logging to file and stderr."""
root = logging.getLogger()
root.setLevel(logging.DEBUG)
formatter = logging.Formatter(
"%(asctime)s %(levelname)s %(name)s: %(message)s"
)
# Always log to stderr
stderr_handler = logging.StreamHandler(sys.stderr)
stderr_handler.setLevel(logging.DEBUG)
stderr_handler.setFormatter(formatter)
root.addHandler(stderr_handler)
# Log to file if the directory is writable
if LOG_DIR.is_dir():
try:
file_handler = logging.FileHandler(LOG_FILE)
file_handler.setLevel(logging.DEBUG)
file_handler.setFormatter(formatter)
root.addHandler(file_handler)
except PermissionError:
logger.warning("Cannot write to %s", LOG_FILE)
class MoongreetApp(Gtk.Application):
"""GTK Application for the Moongreet greeter."""
@@ -34,14 +64,18 @@ class MoongreetApp(Gtk.Application):
def do_activate(self) -> None:
"""Create and present greeter windows on all monitors."""
self._register_icons()
self._load_css()
display = Gdk.Display.get_default()
if display is None:
logger.error("No display available — cannot start greeter UI")
return
self._register_icons(display)
self._load_css(display)
# Resolve wallpaper once, share across all windows
config = load_config()
bg_path, self._wallpaper_ctx = resolve_wallpaper_path(config)
display = Gdk.Display.get_default()
monitors = display.get_monitors()
primary_monitor = None
@@ -81,19 +115,19 @@ class MoongreetApp(Gtk.Application):
self._wallpaper_ctx = None
Gtk.Application.do_shutdown(self)
def _register_icons(self) -> None:
def _register_icons(self, display: Gdk.Display) -> None:
"""Register custom icons from the package data/icons directory."""
icons_dir = files("moongreet") / "data" / "icons"
icon_theme = Gtk.IconTheme.get_for_display(Gdk.Display.get_default())
icon_theme = Gtk.IconTheme.get_for_display(display)
icon_theme.add_search_path(str(icons_dir))
def _load_css(self) -> None:
def _load_css(self, display: Gdk.Display) -> None:
"""Load the CSS stylesheet for the greeter."""
css_provider = Gtk.CssProvider()
css_path = files("moongreet") / "style.css"
css_provider.load_from_path(str(css_path))
Gtk.StyleContext.add_provider_for_display(
Gdk.Display.get_default(),
display,
css_provider,
Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION,
)
@@ -118,6 +152,8 @@ class MoongreetApp(Gtk.Application):
def main() -> None:
"""Run the Moongreet application."""
_setup_logging()
logger.info("Moongreet starting")
app = MoongreetApp()
app.run(sys.argv)
+1 -5
View File
@@ -2,12 +2,9 @@
# ABOUTME: Provides User dataclass and helper functions for the greeter UI.
import configparser
import re
from dataclasses import dataclass
from pathlib import Path
VALID_THEME_NAME = re.compile(r"^[A-Za-z0-9_-]+$")
NOLOGIN_SHELLS = {"/usr/sbin/nologin", "/sbin/nologin", "/bin/false", "/usr/bin/nologin"}
MIN_UID = 1000
MAX_UID = 65533
@@ -106,8 +103,7 @@ def get_user_gtk_theme(config_dir: Path | None = None) -> str | None:
if config.has_option("Settings", "gtk-theme-name"):
theme = config.get("Settings", "gtk-theme-name")
# Validate against path traversal — only allow safe theme names
if theme and VALID_THEME_NAME.match(theme):
if theme:
return theme
return None
+20
View File
@@ -43,6 +43,26 @@ class TestLoadConfig:
assert config.background is None
def test_loads_gtk_theme(self, tmp_path: Path) -> None:
toml_file = tmp_path / "moongreet.toml"
toml_file.write_text(
"[appearance]\n"
'gtk-theme = "Catppuccin-Mocha-Standard-Blue-Dark"\n'
)
config = load_config(toml_file)
assert config.gtk_theme == "Catppuccin-Mocha-Standard-Blue-Dark"
def test_returns_none_gtk_theme_when_missing(self, tmp_path: Path) -> None:
toml_file = tmp_path / "moongreet.toml"
toml_file.write_text("[appearance]\n")
config = load_config(toml_file)
assert config.gtk_theme is None
def test_resolves_relative_path_against_config_dir(self, tmp_path: Path) -> None:
toml_file = tmp_path / "moongreet.toml"
toml_file.write_text(
+102 -5
View File
@@ -10,7 +10,7 @@ from pathlib import Path
import pytest
from moongreet.greeter import faillock_warning, FAILLOCK_MAX_ATTEMPTS
from moongreet.greeter import faillock_warning, FAILLOCK_MAX_ATTEMPTS, LAST_SESSION_DIR
from moongreet.i18n import load_strings
from moongreet.ipc import create_session, post_auth_response, start_session, cancel_session
@@ -59,14 +59,14 @@ class MockGreetd:
header = self._recvall(conn, 4)
if len(header) < 4:
break
length = struct.unpack("!I", header)[0]
length = struct.unpack("=I", header)[0]
payload = self._recvall(conn, length)
msg = json.loads(payload.decode("utf-8"))
self._received.append(msg)
# Send response
resp_payload = json.dumps(response).encode("utf-8")
conn.sendall(struct.pack("!I", len(resp_payload)) + resp_payload)
conn.sendall(struct.pack("=I", len(resp_payload)) + resp_payload)
finally:
conn.close()
@@ -112,12 +112,13 @@ class TestLoginFlow:
assert mock.received[1] == {"type": "post_auth_message_response", "response": "geheim"}
assert mock.received[2] == {"type": "start_session", "cmd": ["Hyprland"]}
def test_wrong_password(self, tmp_path: Path) -> None:
"""Simulate a failed login due to wrong password."""
def test_wrong_password_sends_cancel(self, tmp_path: Path) -> None:
"""After a failed login, cancel_session must be sent to free the greetd session."""
sock_path = tmp_path / "greetd.sock"
mock = MockGreetd(sock_path)
mock.expect({"type": "auth_message", "auth_message_type": "secret", "auth_message": "Password:"})
mock.expect({"type": "error", "error_type": "auth_error", "description": "Authentication failed"})
mock.expect({"type": "success"}) # Response to cancel_session
mock.start()
try:
@@ -131,10 +132,64 @@ class TestLoginFlow:
assert response["type"] == "error"
assert response["description"] == "Authentication failed"
# The greeter must cancel the session after auth failure
response = cancel_session(sock)
assert response["type"] == "success"
sock.close()
finally:
mock.stop()
assert mock.received[2] == {"type": "cancel_session"}
def test_stale_session_cancel_and_retry(self, tmp_path: Path) -> None:
"""When create_session fails due to a stale session, cancel and retry."""
sock_path = tmp_path / "greetd.sock"
mock = MockGreetd(sock_path)
# First create_session → error (stale session)
mock.expect({"type": "error", "error_type": "error", "description": "a session is already being configured"})
# cancel_session → success
mock.expect({"type": "success"})
# Second create_session → auth_message (retry succeeds)
mock.expect({"type": "auth_message", "auth_message_type": "secret", "auth_message": "Password:"})
# post_auth_response → success
mock.expect({"type": "success"})
# start_session → success
mock.expect({"type": "success"})
mock.start()
try:
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.connect(str(sock_path))
# Step 1: Create session fails
response = create_session(sock, "dominik")
assert response["type"] == "error"
# Step 2: Cancel stale session
response = cancel_session(sock)
assert response["type"] == "success"
# Step 3: Retry create session
response = create_session(sock, "dominik")
assert response["type"] == "auth_message"
# Step 4: Send password
response = post_auth_response(sock, "geheim")
assert response["type"] == "success"
# Step 5: Start session
response = start_session(sock, ["niri-session"])
assert response["type"] == "success"
sock.close()
finally:
mock.stop()
assert mock.received[0] == {"type": "create_session", "username": "dominik"}
assert mock.received[1] == {"type": "cancel_session"}
assert mock.received[2] == {"type": "create_session", "username": "dominik"}
def test_multi_stage_auth_sends_cancel(self, tmp_path: Path) -> None:
"""When greetd sends a second auth_message after password, cancel the session."""
sock_path = tmp_path / "greetd.sock"
@@ -264,3 +319,45 @@ class TestLastUser:
from moongreet.greeter import GreeterWindow
result = GreeterWindow._load_last_user()
assert result is None
class TestLastSession:
"""Tests for saving and loading the last session per user."""
def test_save_and_load_last_session(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr("moongreet.greeter.LAST_SESSION_DIR", tmp_path)
from moongreet.greeter import GreeterWindow
GreeterWindow._save_last_session("dominik", "Niri")
session_file = tmp_path / "dominik"
assert session_file.exists()
assert session_file.read_text() == "Niri"
result = GreeterWindow._load_last_session("dominik")
assert result == "Niri"
def test_load_last_session_missing_file(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr("moongreet.greeter.LAST_SESSION_DIR", tmp_path)
from moongreet.greeter import GreeterWindow
result = GreeterWindow._load_last_session("nobody")
assert result is None
def test_load_last_session_rejects_oversized_name(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr("moongreet.greeter.LAST_SESSION_DIR", tmp_path)
(tmp_path / "dominik").write_text("A" * 300)
from moongreet.greeter import GreeterWindow
result = GreeterWindow._load_last_session("dominik")
assert result is None
def test_save_last_session_validates_username(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Usernames with path traversal should not create files outside the cache dir."""
monkeypatch.setattr("moongreet.greeter.LAST_SESSION_DIR", tmp_path)
from moongreet.greeter import GreeterWindow
GreeterWindow._save_last_session("../../etc/evil", "Niri")
# Should not have created any file
assert not (tmp_path / "../../etc/evil").exists()
+12 -12
View File
@@ -38,7 +38,7 @@ class FakeSocket:
def with_response(cls, response: dict) -> "FakeSocket":
"""Create a FakeSocket pre-loaded with a length-prefixed JSON response."""
payload = json.dumps(response).encode("utf-8")
data = struct.pack("!I", len(payload)) + payload
data = struct.pack("=I", len(payload)) + payload
return cls(recv_data=data)
@@ -73,7 +73,7 @@ class TestSendMessage:
send_message(sock, msg)
payload = json.dumps(msg).encode("utf-8")
expected = struct.pack("!I", len(payload)) + payload
expected = struct.pack("=I", len(payload)) + payload
assert bytes(sock.sent) == expected
def test_sends_empty_dict(self) -> None:
@@ -82,7 +82,7 @@ class TestSendMessage:
send_message(sock, {})
payload = json.dumps({}).encode("utf-8")
expected = struct.pack("!I", len(payload)) + payload
expected = struct.pack("=I", len(payload)) + payload
assert bytes(sock.sent) == expected
def test_sends_nested_message(self) -> None:
@@ -93,7 +93,7 @@ class TestSendMessage:
# Verify the payload is correctly length-prefixed
length_bytes = bytes(sock.sent[:4])
length = struct.unpack("!I", length_bytes)[0]
length = struct.unpack("=I", length_bytes)[0]
decoded = json.loads(sock.sent[4:])
assert length == len(json.dumps(msg).encode("utf-8"))
assert decoded == msg
@@ -132,7 +132,7 @@ class TestRecvMessage:
"""recv() may return fewer bytes than requested — must loop."""
response = {"type": "success"}
payload = json.dumps(response).encode("utf-8")
data = struct.pack("!I", len(payload)) + payload
data = struct.pack("=I", len(payload)) + payload
sock = FragmentingSocket(data, chunk_size=3)
result = recv_message(sock)
@@ -141,7 +141,7 @@ class TestRecvMessage:
def test_rejects_oversized_payload(self) -> None:
"""Payloads exceeding the size limit must be rejected."""
header = struct.pack("!I", 10_000_000)
header = struct.pack("=I", 10_000_000)
sock = FakeSocket(recv_data=header)
with pytest.raises(ConnectionError, match="too large"):
@@ -162,7 +162,7 @@ class TestCreateSession:
result = create_session(sock, "dominik")
# Verify sent message
length = struct.unpack("!I", bytes(sock.sent[:4]))[0]
length = struct.unpack("=I", bytes(sock.sent[:4]))[0]
sent_msg = json.loads(sock.sent[4 : 4 + length])
assert sent_msg == {"type": "create_session", "username": "dominik"}
assert result == response
@@ -177,7 +177,7 @@ class TestPostAuthResponse:
result = post_auth_response(sock, "mypassword")
length = struct.unpack("!I", bytes(sock.sent[:4]))[0]
length = struct.unpack("=I", bytes(sock.sent[:4]))[0]
sent_msg = json.loads(sock.sent[4 : 4 + length])
assert sent_msg == {
"type": "post_auth_message_response",
@@ -192,7 +192,7 @@ class TestPostAuthResponse:
result = post_auth_response(sock, None)
length = struct.unpack("!I", bytes(sock.sent[:4]))[0]
length = struct.unpack("=I", bytes(sock.sent[:4]))[0]
sent_msg = json.loads(sock.sent[4 : 4 + length])
assert sent_msg == {
"type": "post_auth_message_response",
@@ -209,7 +209,7 @@ class TestStartSession:
result = start_session(sock, ["Hyprland"])
length = struct.unpack("!I", bytes(sock.sent[:4]))[0]
length = struct.unpack("=I", bytes(sock.sent[:4]))[0]
sent_msg = json.loads(sock.sent[4 : 4 + length])
assert sent_msg == {"type": "start_session", "cmd": ["Hyprland"]}
assert result == response
@@ -220,7 +220,7 @@ class TestStartSession:
result = start_session(sock, ["sway", "--config", "/etc/sway/config"])
length = struct.unpack("!I", bytes(sock.sent[:4]))[0]
length = struct.unpack("=I", bytes(sock.sent[:4]))[0]
sent_msg = json.loads(sock.sent[4 : 4 + length])
assert sent_msg == {
"type": "start_session",
@@ -237,7 +237,7 @@ class TestCancelSession:
result = cancel_session(sock)
length = struct.unpack("!I", bytes(sock.sent[:4]))[0]
length = struct.unpack("=I", bytes(sock.sent[:4]))[0]
sent_msg = json.loads(sock.sent[4 : 4 + length])
assert sent_msg == {"type": "cancel_session"}
assert result == response
+6 -15
View File
@@ -186,27 +186,18 @@ class TestGetUserGtkTheme:
assert result is None
def test_handles_interpolation_characters(self, tmp_path: Path) -> None:
"""Theme names with % characters are rejected by validation."""
def test_passes_theme_with_special_characters(self, tmp_path: Path) -> None:
"""Theme names with special characters are passed through to GTK."""
gtk_dir = tmp_path / ".config" / "gtk-4.0"
gtk_dir.mkdir(parents=True)
settings = gtk_dir / "settings.ini"
settings.write_text("[Settings]\ngtk-theme-name=My%Theme\n")
settings.write_text(
"[Settings]\ngtk-theme-name=catppuccin-mocha-lavender-standard+default\n"
)
result = get_user_gtk_theme(config_dir=gtk_dir)
assert result is None
def test_rejects_path_traversal_theme_name(self, tmp_path: Path) -> None:
"""Theme names with path traversal characters should be rejected."""
gtk_dir = tmp_path / ".config" / "gtk-4.0"
gtk_dir.mkdir(parents=True)
settings = gtk_dir / "settings.ini"
settings.write_text("[Settings]\ngtk-theme-name=../../../../etc/evil\n")
result = get_user_gtk_theme(config_dir=gtk_dir)
assert result is None
assert result == "catppuccin-mocha-lavender-standard+default"
def test_ignores_symlinked_accountsservice_icon(self, tmp_path: Path) -> None:
"""AccountsService icon as symlink should be ignored to prevent traversal."""