From 817a9547ad276830ceb77b3e1b7b29b03d7e9687 Mon Sep 17 00:00:00 2001 From: nevaforget Date: Fri, 27 Mar 2026 23:09:54 +0100 Subject: [PATCH 1/2] Rewrite moonlock from Python to Rust (v0.4.0) Complete rewrite of the Wayland lockscreen from Python/PyGObject to Rust/gtk4-rs for memory safety in security-critical PAM code and consistency with the moonset/moongreet Rust ecosystem. Modules: main, lockscreen, auth (PAM FFI), fingerprint (fprintd D-Bus), config, i18n, users, power. 37 unit tests. Security: PAM conversation callback with Zeroizing password, panic hook that never unlocks, root check, ext-session-lock-v1 compositor policy, absolute loginctl path, avatar symlink rejection. --- .gitignore | 8 +- CLAUDE.md | 53 +- Cargo.lock | 1334 +++++++++++++++++ Cargo.toml | 27 + README.md | 73 + build.rs | 13 + pkg/PKGBUILD | 16 +- pyproject.toml | 30 - .../data => resources}/default-avatar.svg | 0 resources/resources.gresource.xml | 8 + {src/moonlock => resources}/style.css | 38 + .../moonlock/data => resources}/wallpaper.jpg | Bin src/auth.rs | 190 +++ src/config.rs | 87 ++ src/fingerprint.rs | 299 ++++ src/i18n.rs | 140 ++ src/lockscreen.rs | 522 +++++++ src/main.rs | 164 ++ src/moonlock/__init__.py | 2 - src/moonlock/auth.py | 159 -- src/moonlock/config.py | 62 - src/moonlock/fingerprint.py | 201 --- src/moonlock/i18n.py | 111 -- src/moonlock/lockscreen.py | 349 ----- src/moonlock/main.py | 179 --- src/moonlock/power.py | 14 - src/moonlock/users.py | 65 - src/power.rs | 52 + src/users.rs | 93 ++ tests/__init__.py | 0 tests/test_auth.py | 65 - tests/test_config.py | 42 - tests/test_fingerprint.py | 237 --- tests/test_i18n.py | 67 - tests/test_integration.py | 168 --- tests/test_main.py | 229 --- tests/test_power.py | 24 - tests/test_security.py | 24 - tests/test_users.py | 96 -- tests/test_wallpaper.py | 53 - uv.lock | 45 - 41 files changed, 3075 insertions(+), 2264 deletions(-) create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 README.md create mode 100644 build.rs delete mode 100644 pyproject.toml rename {src/moonlock/data => resources}/default-avatar.svg (100%) create mode 100644 resources/resources.gresource.xml rename {src/moonlock => resources}/style.css (67%) rename {src/moonlock/data => resources}/wallpaper.jpg (100%) create mode 100644 src/auth.rs create mode 100644 src/config.rs create mode 100644 src/fingerprint.rs create mode 100644 src/i18n.rs create mode 100644 src/lockscreen.rs create mode 100644 src/main.rs delete mode 100644 src/moonlock/__init__.py delete mode 100644 src/moonlock/auth.py delete mode 100644 src/moonlock/config.py delete mode 100644 src/moonlock/fingerprint.py delete mode 100644 src/moonlock/i18n.py delete mode 100644 src/moonlock/lockscreen.py delete mode 100644 src/moonlock/main.py delete mode 100644 src/moonlock/power.py delete mode 100644 src/moonlock/users.py create mode 100644 src/power.rs create mode 100644 src/users.rs delete mode 100644 tests/__init__.py delete mode 100644 tests/test_auth.py delete mode 100644 tests/test_config.py delete mode 100644 tests/test_fingerprint.py delete mode 100644 tests/test_i18n.py delete mode 100644 tests/test_integration.py delete mode 100644 tests/test_main.py delete mode 100644 tests/test_power.py delete mode 100644 tests/test_security.py delete mode 100644 tests/test_users.py delete mode 100644 tests/test_wallpaper.py delete mode 100644 uv.lock diff --git a/.gitignore b/.gitignore index 092eb08..96f8630 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,4 @@ -__pycache__/ -*.pyc -.venv/ -*.egg-info/ -dist/ -build/ -.pytest_cache/ +/target # makepkg build artifacts pkg/src/ diff --git a/CLAUDE.md b/CLAUDE.md index 19eeb82..97f725f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,50 +4,53 @@ ## Projekt -Moonlock ist ein sicherer Wayland-Lockscreen, gebaut mit Python + GTK4 + ext-session-lock-v1. -Teil des Moonarch-Ökosystems. Visuell und architektonisch inspiriert von Moongreet. +Moonlock ist ein sicherer Wayland-Lockscreen, gebaut mit Rust + gtk4-rs + ext-session-lock-v1. +Teil des Moonarch-Ökosystems. ## Tech-Stack -- Python 3.11+, PyGObject (GTK 4.0) -- Gtk4SessionLock (ext-session-lock-v1) für protokoll-garantiertes Screen-Locking -- PAM-Authentifizierung via ctypes-Wrapper (libpam.so.0) -- fprintd D-Bus Integration (Gio.DBusProxy) für Fingerabdruck-Unlock -- pytest für Tests +- Rust (Edition 2024), gtk4-rs 0.11, glib 0.22 +- gtk4-session-lock 0.4 für ext-session-lock-v1 Protokoll +- PAM-Authentifizierung via Raw FFI (libc, libpam.so) +- fprintd D-Bus Integration (gio::DBusProxy) für Fingerabdruck-Unlock +- zeroize für sicheres Passwort-Wiping +- `cargo test` für Unit-Tests ## Projektstruktur -- `src/moonlock/` — Quellcode -- `src/moonlock/data/` — Package-Assets (Default-Avatar, Icons) -- `tests/` — pytest Tests -- `config/` — Beispiel-Konfigurationsdateien +- `src/` — Rust-Quellcode (main.rs, lockscreen.rs, auth.rs, fingerprint.rs, config.rs, i18n.rs, users.rs, power.rs) +- `resources/` — GResource-Assets (style.css, wallpaper.jpg, default-avatar.svg) +- `config/` — PAM-Konfiguration und Beispiel-Config ## Kommandos ```bash # Tests ausführen -uv run pytest tests/ -v +cargo test -# Typ-Checks -uv run pyright src/ +# Release-Build +cargo build --release # Lockscreen starten (zum Testen) -uv run moonlock +LD_PRELOAD=/usr/lib/libgtk4-layer-shell.so ./target/release/moonlock ``` ## Architektur -- `auth.py` — PAM-Authentifizierung via ctypes (libpam.so.0) -- `fingerprint.py` — fprintd D-Bus Listener (Gio.DBusProxy, async im GLib-Mainloop) -- `users.py` — Aktuellen User ermitteln, Avatar laden -- `power.py` — Reboot/Shutdown via loginctl -- `i18n.py` — Locale-Erkennung und String-Tabellen (DE/EN) -- `lockscreen.py` — GTK4 UI (Avatar, Passwort-Entry, Fingerprint-Indikator, Power-Buttons) -- `main.py` — Entry Point, GTK App, Session Lock Setup (ext-session-lock-v1) +- `auth.rs` — PAM-Authentifizierung via Raw FFI (unsafe extern "C" conv callback, Zeroizing>) +- `fingerprint.rs` — fprintd D-Bus Listener (gio::DBusProxy, VerifyStatus Signal-Handling) +- `users.rs` — Aktuellen User via nix getuid, Avatar-Loading mit Symlink-Rejection +- `power.rs` — Reboot/Shutdown via /usr/bin/loginctl +- `i18n.rs` — Locale-Erkennung und String-Tabellen (DE/EN) +- `config.rs` — TOML-Config (background_path, fingerprint_enabled) + Wallpaper-Fallback +- `lockscreen.rs` — GTK4 UI, PAM-Auth via gio::spawn_blocking, Fingerprint-Indikator, Power-Confirm +- `main.rs` — Entry Point, Panic-Hook, Root-Check, ext-session-lock-v1, Multi-Monitor ## Sicherheit - ext-session-lock-v1 garantiert: Compositor sperrt alle Surfaces bei lock() -- Bei Crash bleibt Screen schwarz (nicht offen) -- Passwort wird nach Verwendung im Speicher überschrieben -- Kein Schließen per Escape/Alt-F4 — nur durch erfolgreiche Auth +- Panic-Hook: Bei Crash wird geloggt, aber NIEMALS unlock() aufgerufen — Screen bleibt schwarz +- Passwort: Zeroizing> für sicheres Wiping nach PAM-Callback +- Root-Check: Exit mit Fehler wenn als root gestartet +- Kein Schließen per Escape/Alt-F4 — nur durch erfolgreiche PAM-Auth oder Fingerprint +- GResource-Bundle: CSS/Assets in der Binary kompiliert diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..59bc404 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1334 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "cairo-rs" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc8d9aa793480744cd9a0524fef1a2e197d9eaa0f739cde19d16aba530dcb95" +dependencies = [ + "bitflags", + "cairo-sys-rs", + "glib", + "libc", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8b4985713047f5faee02b8db6a6ef32bbb50269ff53c1aee716d1d195b76d54" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "cfg-expr" +version = "0.20.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c6b04e07d8080154ed4ac03546d9a2b303cc2fe1901ba0b35b301516e289368" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "env_filter" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e90c2accc4b07a8456ea0debdc2e7587bdd890680d71173a15d4ae604f6eef" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-macro", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25f420376dbee041b2db374ce4573892a36222bb3f6c0c43e24f0d67eae9b646" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f31b37b1fc4b48b54f6b91b7ef04c18e00b4585d98359dd7b998774bbd91fb" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk4" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa528049fd8726974a7aa1a6e1421f891e7579bea6cc6d54056ab4d1a1b937e7" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk4-sys", + "gio", + "gl", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk4-sys" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3dd48b1b03dce78ab52805ac35cfb69c48af71a03af5723231d8583718738377" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "gio" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "816b6743c46b217aa8fba679095ac6f2162fd53259dc8f186fcdbff9c555db03" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "pin-project-lite", + "smallvec", +] + +[[package]] +name = "gio-sys" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64729ba2772c080448f9f966dba8f4456beeb100d8c28a865ef8a0f2ef4987e1" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "windows-sys", +] + +[[package]] +name = "gl" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a94edab108827d67608095e269cf862e60d920f144a5026d3dbcfd8b877fb404" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + +[[package]] +name = "glib" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "039f93465ac17e6cb02d16f16572cd3e43a77e736d5ecc461e71b9c9c5c0569c" +dependencies = [ + "bitflags", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "smallvec", +] + +[[package]] +name = "glib-build-tools" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bf3abfd1e60b194dded50f802277c68a59121a5a221701102f02db223cdda27" +dependencies = [ + "gio", +] + +[[package]] +name = "glib-macros" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bda575994e3689b1bc12f89c3df621ead46ff292623b76b4710a3a5b79be54bb" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "glib-sys" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1eb23a616a3dbc7fc15bbd26f58756ff0b04c8a894df3f0680cd21011db6a642" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "gobject-sys" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18eda93f09d3778f38255b231b17ef67195013a592c91624a4daf8bead875565" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "graphene-rs" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7d1b7881f96869f49808b6adfe906a93a57a34204952253444d68c3208d71f1" +dependencies = [ + "glib", + "graphene-sys", + "libc", +] + +[[package]] +name = "graphene-sys" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "517f062f3fd6b7fd3e57a3f038a74b3c23ca32f51199ff028aa704609943f79c" +dependencies = [ + "glib-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gsk4" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c912dfcbd28acace5fc99c40bb9f25e1dcb73efb1f2608327f66a99acdcb62" +dependencies = [ + "cairo-rs", + "gdk4", + "glib", + "graphene-rs", + "gsk4-sys", + "libc", + "pango", +] + +[[package]] +name = "gsk4-sys" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7d54bbc7a9d8b6ffe4f0c95eede15ccfb365c8bf521275abe6bcfb57b18fb8a" +dependencies = [ + "cairo-sys-rs", + "gdk4-sys", + "glib-sys", + "gobject-sys", + "graphene-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk4" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87f671029e3f5288fd35e03a6e6b19e1ce643b10a3d261d33d183e453f6c52fe" +dependencies = [ + "cairo-rs", + "field-offset", + "futures-channel", + "gdk-pixbuf", + "gdk4", + "gio", + "glib", + "graphene-rs", + "gsk4", + "gtk4-macros", + "gtk4-sys", + "libc", + "pango", +] + +[[package]] +name = "gtk4-macros" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3581b242ba62fdff122ebb626ea641582ec326031622bd19d60f85029c804a87" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "gtk4-session-lock" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2267b4acc07f3125a5cea6d27254a737125a722de4fcc45aa3ad1f1f543af35" +dependencies = [ + "gdk4", + "glib", + "glib-sys", + "gtk4", + "gtk4-session-lock-sys", + "libc", +] + +[[package]] +name = "gtk4-session-lock-sys" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "141184b5b7b5951ae55012c338a0f681ee58af63b5d4ff7e3166c6f2229b1f9b" +dependencies = [ + "gdk4-sys", + "glib-sys", + "gobject-sys", + "gtk4-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk4-sys" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0786e7e8e0550d0ab2df4d0d90032f22033e07d5ed78b6a1b2e51b05340339e" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk4-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "graphene-sys", + "gsk4-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jiff" +version = "0.2.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359" +dependencies = [ + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-static" +version = "0.2.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.183" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "moonlock" +version = "0.4.0" +dependencies = [ + "env_logger", + "gdk-pixbuf", + "gdk4", + "gio", + "glib", + "glib-build-tools", + "gtk4", + "gtk4-session-lock", + "libc", + "log", + "nix", + "serde", + "tempfile", + "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]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "pango" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25d8f224eddef627b896d2f7b05725b3faedbd140e0e8343446f0d34f34238ee" +dependencies = [ + "gio", + "glib", + "libc", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd111a20ca90fedf03e09c59783c679c00900f1d8491cca5399f5e33609d5d6" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "091397be61a01d4be58e7841595bd4bfedb15f1cd54977d79b8271e94ed799a3" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.8+spec-1.1.0", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "876ac351060d4f882bb1032b6369eb0aef79ad9df1ea8bc404874d8cc3d0cd98" +dependencies = [ + "serde_core", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "system-deps" +version = "7.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c8f33736f986f16d69b6cb8b03f55ddcad5c41acc4ccc39dd88e84aa805e7f" +dependencies = [ + "cfg-expr", + "heck", + "pkg-config", + "toml 0.9.12+spec-1.1.0", + "version-compare", +] + +[[package]] +name = "target-lexicon" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned 1.1.0", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_datetime" +version = "1.1.0+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97251a7c317e03ad83774a8752a7e81fb6067740609f75ea2b585b569a59198f" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", +] + +[[package]] +name = "toml_edit" +version = "0.25.8+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16bff38f1d86c47f9ff0647e6838d7bb362522bdf44006c7068c2b1e606f1f3c" +dependencies = [ + "indexmap", + "toml_datetime 1.1.0+spec-1.1.0", + "toml_parser", + "winnow 1.0.0", +] + +[[package]] +name = "toml_parser" +version = "1.1.0+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011" +dependencies = [ + "winnow 1.0.0", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "toml_writer" +version = "1.1.0+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d282ade6016312faf3e41e57ebbba0c073e4056dab1232ab1cb624199648f8ed" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..019d92f --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "moonlock" +version = "0.4.0" +edition = "2024" +description = "A secure Wayland lockscreen with GTK4, PAM and fingerprint support" +license = "MIT" + +[dependencies] +gtk4 = { version = "0.11", features = ["v4_10"] } +gtk4-session-lock = { version = "0.4", features = ["v1_1"] } +glib = "0.22" +gdk4 = "0.11" +gdk-pixbuf = "0.22" +gio = "0.22" +toml = "0.8" +serde = { version = "1", features = ["derive"] } +nix = { version = "0.29", features = ["user"] } +zeroize = { version = "1", features = ["derive"] } +libc = "0.2" +log = "0.4" +env_logger = "0.11" + +[dev-dependencies] +tempfile = "3" + +[build-dependencies] +glib-build-tools = "0.22" diff --git a/README.md b/README.md new file mode 100644 index 0000000..03096a3 --- /dev/null +++ b/README.md @@ -0,0 +1,73 @@ +# Moonlock + +A secure Wayland lockscreen with GTK4, PAM authentication and fingerprint support. +Part of the Moonarch ecosystem. + +## Features + +- **ext-session-lock-v1** — Protocol-guaranteed screen locking (compositor keeps screen locked on crash) +- **PAM authentication** — Uses system PAM stack (`/etc/pam.d/moonlock`) +- **Fingerprint unlock** — fprintd D-Bus integration (optional) +- **Multi-monitor** — Lockscreen on every monitor +- **i18n** — German and English (auto-detected) +- **Faillock warning** — UI counter + system pam_faillock +- **Panic safety** — Panic hook logs but never unlocks +- **Password wiping** — Zeroize on drop + +## Requirements + +- GTK 4 +- gtk4-session-lock (ext-session-lock-v1 support) +- PAM (`/etc/pam.d/moonlock`) +- Optional: fprintd for fingerprint support + +## Building + +```bash +cargo build --release +``` + +## Installation + +```bash +# Install binary +sudo install -Dm755 target/release/moonlock /usr/bin/moonlock + +# Install PAM config +sudo install -Dm644 config/moonlock-pam /etc/pam.d/moonlock + +# Optional: Install example config +sudo install -Dm644 config/moonlock.toml.example /etc/moonlock/moonlock.toml.example +``` + +## Configuration + +Create `/etc/moonlock/moonlock.toml` or `~/.config/moonlock/moonlock.toml`: + +```toml +background_path = "/usr/share/wallpapers/moon.jpg" +fingerprint_enabled = true +``` + +## Usage + +Typically launched via keybind in your Wayland compositor: + +``` +# Niri keybind example +binds { + Mod+L { spawn "moonlock"; } +} +``` + +## Development + +```bash +cargo test +cargo build --release +LD_PRELOAD=/usr/lib/libgtk4-layer-shell.so ./target/release/moonlock +``` + +## License + +MIT diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..65b47e6 --- /dev/null +++ b/build.rs @@ -0,0 +1,13 @@ +// ABOUTME: Build script for compiling GResource bundle. +// ABOUTME: Bundles style.css, wallpaper.jpg, and default-avatar.svg into the binary. + +fn main() { + glib_build_tools::compile_resources( + &["resources"], + "resources/resources.gresource.xml", + "moonlock.gresource", + ); + + // Link libpam for PAM authentication + println!("cargo:rustc-link-lib=pam"); +} diff --git a/pkg/PKGBUILD b/pkg/PKGBUILD index 70a1942..f66ff12 100644 --- a/pkg/PKGBUILD +++ b/pkg/PKGBUILD @@ -4,24 +4,21 @@ # Maintainer: Dominik Kressler pkgname=moonlock-git -pkgver=0.2.0.r0.g7cee4f4 +pkgver=0.4.0.r0.g0000000 pkgrel=1 pkgdesc="A secure Wayland lockscreen with GTK4, PAM and fingerprint support" -arch=('any') +arch=('x86_64') url="https://gitea.moonarch.de/nevaforget/moonlock" license=('MIT') depends=( - 'python' - 'python-gobject' 'gtk4' 'gtk4-layer-shell' + 'gtk4-session-lock' 'pam' ) makedepends=( 'git' - 'python-build' - 'python-installer' - 'python-hatchling' + 'cargo' ) optdepends=( 'fprintd: fingerprint authentication support' @@ -38,13 +35,12 @@ pkgver() { build() { cd "$srcdir/moonlock" - rm -rf dist/ - python -m build --wheel --no-isolation + cargo build --release --locked } package() { cd "$srcdir/moonlock" - python -m installer --destdir="$pkgdir" dist/*.whl + install -Dm755 target/release/moonlock "$pkgdir/usr/bin/moonlock" # PAM configuration install -Dm644 config/moonlock-pam "$pkgdir/etc/pam.d/moonlock" diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index 4aeae53..0000000 --- a/pyproject.toml +++ /dev/null @@ -1,30 +0,0 @@ -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[project] -name = "moonlock" -version = "0.3.0" -description = "A secure Wayland lockscreen with GTK4, PAM and fingerprint support" -requires-python = ">=3.11" -license = "MIT" -dependencies = [ - "PyGObject>=3.46", -] - -[project.scripts] -moonlock = "moonlock.main:main" - -[tool.hatch.build.targets.wheel] -packages = ["src/moonlock"] - -[tool.pytest.ini_options] -testpaths = ["tests"] -pythonpath = ["src"] - -[tool.pyright] -pythonVersion = "3.11" -pythonPlatform = "Linux" -venvPath = "." -venv = ".venv" -typeCheckingMode = "standard" diff --git a/src/moonlock/data/default-avatar.svg b/resources/default-avatar.svg similarity index 100% rename from src/moonlock/data/default-avatar.svg rename to resources/default-avatar.svg diff --git a/resources/resources.gresource.xml b/resources/resources.gresource.xml new file mode 100644 index 0000000..84f8548 --- /dev/null +++ b/resources/resources.gresource.xml @@ -0,0 +1,8 @@ + + + + style.css + wallpaper.jpg + default-avatar.svg + + diff --git a/src/moonlock/style.css b/resources/style.css similarity index 67% rename from src/moonlock/style.css rename to resources/style.css index 690e14f..9fb680c 100644 --- a/src/moonlock/style.css +++ b/resources/style.css @@ -6,6 +6,12 @@ window.lockscreen { background-color: #1a1a2e; background-size: cover; background-position: center; + opacity: 0; + transition: opacity 350ms ease-in; +} + +window.lockscreen.visible { + opacity: 1; } /* Central login area */ @@ -59,6 +65,38 @@ window.lockscreen { color: #ff6b6b; } +/* Confirmation prompt */ +.confirm-label { + font-size: 16px; + color: white; + margin-bottom: 4px; +} + +.confirm-yes { + padding: 8px 24px; + border-radius: 8px; + background-color: @error_color; + color: @theme_bg_color; + border: none; + font-weight: bold; +} + +.confirm-yes:hover { + background-color: lighter(@error_color); +} + +.confirm-no { + padding: 8px 24px; + border-radius: 8px; + background-color: @theme_unfocused_bg_color; + color: @theme_fg_color; + border: none; +} + +.confirm-no:hover { + background-color: @theme_selected_bg_color; +} + /* Power buttons on the bottom right */ .power-button { min-width: 48px; diff --git a/src/moonlock/data/wallpaper.jpg b/resources/wallpaper.jpg similarity index 100% rename from src/moonlock/data/wallpaper.jpg rename to resources/wallpaper.jpg diff --git a/src/auth.rs b/src/auth.rs new file mode 100644 index 0000000..6a16910 --- /dev/null +++ b/src/auth.rs @@ -0,0 +1,190 @@ +// ABOUTME: PAM authentication via raw FFI wrapper around libpam.so. +// ABOUTME: Provides authenticate(username, password) for the lockscreen with secure password wiping. + +use std::ffi::CString; +use std::ptr; +use zeroize::Zeroizing; + +// PAM return codes +const PAM_SUCCESS: i32 = 0; + +/// PAM message structure (pam_message). +#[repr(C)] +struct PamMessage { + msg_style: libc::c_int, + msg: *const libc::c_char, +} + +/// PAM response structure (pam_response). +#[repr(C)] +struct PamResponse { + resp: *mut libc::c_char, + resp_retcode: libc::c_int, +} + +/// PAM conversation callback type. +type PamConvFn = unsafe extern "C" fn( + num_msg: libc::c_int, + msg: *mut *const PamMessage, + resp: *mut *mut PamResponse, + appdata_ptr: *mut libc::c_void, +) -> libc::c_int; + +/// PAM conversation structure (pam_conv). +#[repr(C)] +struct PamConv { + conv: PamConvFn, + appdata_ptr: *mut libc::c_void, +} + +// libpam function declarations +unsafe extern "C" { + fn pam_start( + service_name: *const libc::c_char, + user: *const libc::c_char, + pam_conversation: *const PamConv, + pamh: *mut *mut libc::c_void, + ) -> libc::c_int; + + fn pam_authenticate(pamh: *mut libc::c_void, flags: libc::c_int) -> libc::c_int; + + fn pam_acct_mgmt(pamh: *mut libc::c_void, flags: libc::c_int) -> libc::c_int; + + fn pam_end(pamh: *mut libc::c_void, pam_status: libc::c_int) -> libc::c_int; +} + +/// PAM conversation callback — provides the password to PAM. +/// +/// # Safety +/// Called by libpam during authentication. Allocates response memory with calloc/strdup +/// which PAM will free. The appdata_ptr must point to a valid CString (the password). +unsafe extern "C" fn pam_conv_callback( + num_msg: libc::c_int, + _msg: *mut *const PamMessage, + resp: *mut *mut PamResponse, + appdata_ptr: *mut libc::c_void, +) -> libc::c_int { + // Safety: appdata_ptr was set to a valid *const CString in authenticate() + let password = appdata_ptr as *const CString; + if password.is_null() { + return 7; // PAM_AUTH_ERR + } + + // Safety: calloc returns zeroed memory for num_msg PamResponse structs. + // PAM owns this memory and will free() it. + let resp_array = libc::calloc( + num_msg as libc::size_t, + std::mem::size_of::() as libc::size_t, + ) as *mut PamResponse; + + if resp_array.is_null() { + return 7; // PAM_AUTH_ERR + } + + for i in 0..num_msg as isize { + // Safety: strdup allocates with malloc — PAM will free() the resp strings. + // We dereference password which is valid for the lifetime of authenticate(). + let resp_ptr = resp_array.offset(i); + (*resp_ptr).resp = libc::strdup((*password).as_ptr()); + (*resp_ptr).resp_retcode = 0; + } + + // Safety: resp is a valid pointer provided by PAM + *resp = resp_array; + PAM_SUCCESS +} + +/// Authenticate a user via PAM. +/// +/// Returns true on success, false on authentication failure. +/// The password is wiped from memory after use via zeroize. +pub fn authenticate(username: &str, password: &str) -> bool { + // Use Zeroizing to ensure password bytes are wiped on drop + let password_bytes = Zeroizing::new(password.as_bytes().to_vec()); + let password_cstr = match CString::new(password_bytes.as_slice()) { + Ok(c) => c, + Err(_) => return false, // Password contains null byte + }; + + let service = match CString::new("moonlock") { + Ok(c) => c, + Err(_) => return false, + }; + + let username_cstr = match CString::new(username) { + Ok(c) => c, + Err(_) => return false, + }; + + let conv = PamConv { + conv: pam_conv_callback, + appdata_ptr: &password_cstr as *const CString as *mut libc::c_void, + }; + + let mut handle: *mut libc::c_void = ptr::null_mut(); + + // Safety: All pointers are valid CStrings that outlive the pam_start call. + // handle is an output parameter that PAM will set. + let ret = unsafe { + pam_start( + service.as_ptr(), + username_cstr.as_ptr(), + &conv, + &mut handle, + ) + }; + + if ret != PAM_SUCCESS { + return false; + } + + // Safety: handle is valid after successful pam_start + let auth_ret = unsafe { pam_authenticate(handle, 0) }; + let acct_ret = if auth_ret == PAM_SUCCESS { + // Safety: handle is valid, check account restrictions + unsafe { pam_acct_mgmt(handle, 0) } + } else { + auth_ret + }; + + // Safety: handle is valid, pam_end cleans up the PAM session + unsafe { pam_end(handle, acct_ret) }; + + acct_ret == PAM_SUCCESS +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pam_service_name_is_valid_cstring() { + let service = CString::new("moonlock").unwrap(); + assert_eq!(service.as_bytes(), b"moonlock"); + } + + #[test] + fn password_with_null_byte_fails() { + // authenticate should return false for passwords with embedded nulls + let result = authenticate("testuser", "pass\0word"); + assert!(!result); + } + + #[test] + fn zeroizing_wipes_password() { + let password = Zeroizing::new(vec![0x41u8, 0x42, 0x43]); + let ptr = password.as_ptr(); + drop(password); + // After drop, the memory should be zeroed (though we can't reliably + // test this since the allocator may reuse the memory). This test + // verifies the Zeroizing wrapper compiles and drops correctly. + assert!(!ptr.is_null()); + } + + #[test] + fn empty_username_fails() { + // Empty username should not crash, just fail auth + let result = authenticate("", "password"); + assert!(!result); + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..fb11c91 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,87 @@ +// ABOUTME: Configuration loading for the lockscreen. +// ABOUTME: Reads moonlock.toml for wallpaper and fingerprint settings with fallback hierarchy. + +use serde::Deserialize; +use std::fs; +use std::path::{Path, PathBuf}; + +const MOONARCH_WALLPAPER: &str = "/usr/share/moonarch/wallpaper.jpg"; +const GRESOURCE_PREFIX: &str = "/dev/moonarch/moonlock"; + +fn default_config_paths() -> Vec { + let mut paths = vec![PathBuf::from("/etc/moonlock/moonlock.toml")]; + if let Some(home) = std::env::var_os("HOME") { + paths.push(PathBuf::from(home).join(".config/moonlock/moonlock.toml")); + } + paths +} + +#[derive(Debug, Clone, Default, Deserialize)] +pub struct Config { + pub background_path: Option, + #[serde(default = "default_fingerprint")] + pub fingerprint_enabled: bool, +} + +fn default_fingerprint() -> bool { true } + +pub fn load_config(config_paths: Option<&[PathBuf]>) -> Config { + let default_paths = default_config_paths(); + let paths = config_paths.unwrap_or(&default_paths); + let mut merged = Config { fingerprint_enabled: true, ..Config::default() }; + for path in paths { + if let Ok(content) = fs::read_to_string(path) { + if let Ok(parsed) = toml::from_str::(&content) { + if parsed.background_path.is_some() { merged.background_path = parsed.background_path; } + merged.fingerprint_enabled = parsed.fingerprint_enabled; + } + } + } + merged +} + +pub fn resolve_background_path(config: &Config) -> PathBuf { + resolve_background_path_with(config, Path::new(MOONARCH_WALLPAPER)) +} + +pub fn resolve_background_path_with(config: &Config, moonarch_wallpaper: &Path) -> PathBuf { + if let Some(ref bg) = config.background_path { + let path = PathBuf::from(bg); + if path.is_file() { return path; } + } + if moonarch_wallpaper.is_file() { return moonarch_wallpaper.to_path_buf(); } + PathBuf::from(format!("{GRESOURCE_PREFIX}/wallpaper.jpg")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] fn default_config() { let c = Config::default(); assert!(c.background_path.is_none()); assert!(!c.fingerprint_enabled); } + #[test] fn load_default_fingerprint_true() { + let dir = tempfile::tempdir().unwrap(); + let conf = dir.path().join("moonlock.toml"); + fs::write(&conf, "").unwrap(); + let c = load_config(Some(&[conf])); + assert!(c.fingerprint_enabled); + } + #[test] fn load_background() { + let dir = tempfile::tempdir().unwrap(); + let conf = dir.path().join("moonlock.toml"); + fs::write(&conf, "background_path = \"/custom/bg.jpg\"\nfingerprint_enabled = false\n").unwrap(); + let c = load_config(Some(&[conf])); + assert_eq!(c.background_path.as_deref(), Some("/custom/bg.jpg")); + assert!(!c.fingerprint_enabled); + } + #[test] fn resolve_config_path() { + let dir = tempfile::tempdir().unwrap(); + let wp = dir.path().join("bg.jpg"); fs::write(&wp, "fake").unwrap(); + let c = Config { background_path: Some(wp.to_str().unwrap().to_string()), fingerprint_enabled: true }; + assert_eq!(resolve_background_path_with(&c, Path::new("/nonexistent")), wp); + } + #[test] fn resolve_gresource_fallback() { + let c = Config::default(); + let r = resolve_background_path_with(&c, Path::new("/nonexistent")); + assert!(r.to_str().unwrap().contains("moonlock")); + } +} diff --git a/src/fingerprint.rs b/src/fingerprint.rs new file mode 100644 index 0000000..224c053 --- /dev/null +++ b/src/fingerprint.rs @@ -0,0 +1,299 @@ +// ABOUTME: fprintd D-Bus integration for fingerprint authentication. +// ABOUTME: Provides FingerprintListener that connects to fprintd via Gio.DBusProxy. + +use gio::prelude::*; +use gtk4::gio; + +const FPRINTD_BUS_NAME: &str = "net.reactivated.Fprint"; +const FPRINTD_MANAGER_PATH: &str = "/net/reactivated/Fprint/Manager"; +const FPRINTD_MANAGER_IFACE: &str = "net.reactivated.Fprint.Manager"; +const FPRINTD_DEVICE_IFACE: &str = "net.reactivated.Fprint.Device"; + +const MAX_FP_ATTEMPTS: u32 = 10; + +/// Retry-able statuses — finger not read properly, try again. +const RETRY_STATUSES: &[&str] = &[ + "verify-swipe-too-short", + "verify-finger-not-centered", + "verify-remove-and-retry", + "verify-retry-scan", +]; + +/// Fingerprint listener state. +pub struct FingerprintListener { + device_proxy: Option, + signal_id: Option, + running: bool, + failed_attempts: u32, + on_success: Option>, + on_failure: Option>, +} + +impl FingerprintListener { + /// Create a new FingerprintListener. + /// Connects to fprintd synchronously — call before creating GTK windows. + pub fn new() -> Self { + let mut listener = FingerprintListener { + device_proxy: None, + signal_id: None, + running: false, + failed_attempts: 0, + on_success: None, + on_failure: None, + }; + listener.init_device(); + listener + } + + /// Connect to fprintd and get the default device. + fn init_device(&mut self) { + let manager = match gio::DBusProxy::for_bus_sync( + gio::BusType::System, + gio::DBusProxyFlags::NONE, + None, + FPRINTD_BUS_NAME, + FPRINTD_MANAGER_PATH, + FPRINTD_MANAGER_IFACE, + gio::Cancellable::NONE, + ) { + Ok(m) => m, + Err(e) => { + log::debug!("fprintd manager not available: {e}"); + return; + } + }; + + // Call GetDefaultDevice + let result = match manager.call_sync( + "GetDefaultDevice", + None, + gio::DBusCallFlags::NONE, + -1, + gio::Cancellable::NONE, + ) { + Ok(r) => r, + Err(e) => { + log::debug!("fprintd GetDefaultDevice failed: {e}"); + return; + } + }; + + // Extract device path from variant tuple + let device_path: String = result.child_get::(0); + if device_path.is_empty() { + return; + } + + match gio::DBusProxy::for_bus_sync( + gio::BusType::System, + gio::DBusProxyFlags::NONE, + None, + FPRINTD_BUS_NAME, + &device_path, + FPRINTD_DEVICE_IFACE, + gio::Cancellable::NONE, + ) { + Ok(proxy) => { + self.device_proxy = Some(proxy); + } + Err(e) => { + log::debug!("fprintd device proxy failed: {e}"); + } + } + } + + /// Check if fprintd is available and the user has enrolled fingerprints. + pub fn is_available(&self, username: &str) -> bool { + let proxy = match &self.device_proxy { + Some(p) => p, + None => return false, + }; + + let args = glib::Variant::from((&username,)); + match proxy.call_sync( + "ListEnrolledFingers", + Some(&args), + gio::DBusCallFlags::NONE, + -1, + gio::Cancellable::NONE, + ) { + Ok(result) => { + // Result is a tuple of (array of strings) + let fingers: Vec = result.child_get::>(0); + !fingers.is_empty() + } + Err(_) => false, + } + } + + /// Whether the listener is currently running. + pub fn is_running(&self) -> bool { + self.running + } + + /// Start listening for fingerprint verification. + pub fn start(&mut self, username: &str, on_success: F, on_failure: G) + where + F: Fn() + 'static, + G: Fn() + 'static, + { + let proxy = match &self.device_proxy { + Some(p) => p, + None => return, + }; + + self.on_success = Some(Box::new(on_success)); + self.on_failure = Some(Box::new(on_failure)); + + // Claim the device + let args = glib::Variant::from((&username,)); + if let Err(e) = proxy.call_sync( + "Claim", + Some(&args), + gio::DBusCallFlags::NONE, + -1, + gio::Cancellable::NONE, + ) { + log::error!("Failed to claim fingerprint device: {e}"); + return; + } + + // Start verification + let start_args = glib::Variant::from((&"any",)); + if let Err(e) = proxy.call_sync( + "VerifyStart", + Some(&start_args), + gio::DBusCallFlags::NONE, + -1, + gio::Cancellable::NONE, + ) { + log::error!("Failed to start fingerprint verification: {e}"); + let _ = proxy.call_sync( + "Release", + None, + gio::DBusCallFlags::NONE, + -1, + gio::Cancellable::NONE, + ); + return; + } + + self.running = true; + + // Note: Signal handling is set up by the caller via connect_g_signal() + // because FingerprintListener is not an Rc and the g-signal callback + // needs to reference mutable state. The caller (lockscreen.rs) will + // connect the proxy's "g-signal" and call on_verify_status(). + } + + /// Process a VerifyStatus signal from fprintd. + pub fn on_verify_status(&mut self, status: &str, done: bool) { + if !self.running { + return; + } + + if status == "verify-match" { + if let Some(ref cb) = self.on_success { + cb(); + } + return; + } + + if RETRY_STATUSES.contains(&status) { + if done { + self.restart_verify(); + } + return; + } + + if status == "verify-no-match" { + self.failed_attempts += 1; + if let Some(ref cb) = self.on_failure { + cb(); + } + if self.failed_attempts >= MAX_FP_ATTEMPTS { + log::warn!("Fingerprint max attempts ({MAX_FP_ATTEMPTS}) reached, stopping"); + self.stop(); + return; + } + if done { + self.restart_verify(); + } + return; + } + + log::debug!("Unhandled fprintd status: {status}"); + } + + /// Restart fingerprint verification after a completed attempt. + fn restart_verify(&self) { + if let Some(ref proxy) = self.device_proxy { + let args = glib::Variant::from((&"any",)); + if let Err(e) = proxy.call_sync( + "VerifyStart", + Some(&args), + gio::DBusCallFlags::NONE, + -1, + gio::Cancellable::NONE, + ) { + log::error!("Failed to restart fingerprint verification: {e}"); + } + } + } + + /// Stop listening and release the device. + pub fn stop(&mut self) { + if !self.running { + return; + } + self.running = false; + + if let Some(ref proxy) = self.device_proxy { + if let Some(id) = self.signal_id.take() { + proxy.disconnect(id); + } + let _ = proxy.call_sync( + "VerifyStop", + None, + gio::DBusCallFlags::NONE, + -1, + gio::Cancellable::NONE, + ); + let _ = proxy.call_sync( + "Release", + None, + gio::DBusCallFlags::NONE, + -1, + gio::Cancellable::NONE, + ); + } + } + + /// Get a reference to the device proxy for signal connection. + pub fn device_proxy(&self) -> Option<&gio::DBusProxy> { + self.device_proxy.as_ref() + } + + /// Store the signal handler ID for cleanup. + pub fn set_signal_id(&mut self, id: glib::SignalHandlerId) { + self.signal_id = Some(id); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn retry_statuses_are_defined() { + assert!(RETRY_STATUSES.contains(&"verify-swipe-too-short")); + assert!(RETRY_STATUSES.contains(&"verify-finger-not-centered")); + assert!(!RETRY_STATUSES.contains(&"verify-match")); + assert!(!RETRY_STATUSES.contains(&"verify-no-match")); + } + + #[test] + fn max_attempts_constant() { + assert_eq!(MAX_FP_ATTEMPTS, 10); + } +} diff --git a/src/i18n.rs b/src/i18n.rs new file mode 100644 index 0000000..340f220 --- /dev/null +++ b/src/i18n.rs @@ -0,0 +1,140 @@ +// ABOUTME: Locale detection and string lookup for the lockscreen UI. +// ABOUTME: Reads system locale (LANG or /etc/locale.conf) and provides DE or EN strings. + +use std::env; +use std::fs; +use std::path::Path; + +const DEFAULT_LOCALE_CONF: &str = "/etc/locale.conf"; + +#[derive(Debug, Clone)] +pub struct Strings { + pub password_placeholder: &'static str, + pub unlock_button: &'static str, + pub reboot_tooltip: &'static str, + pub shutdown_tooltip: &'static str, + pub fingerprint_prompt: &'static str, + pub fingerprint_success: &'static str, + pub fingerprint_failed: &'static str, + pub auth_failed: &'static str, + pub wrong_password: &'static str, + pub reboot_failed: &'static str, + pub shutdown_failed: &'static str, + pub reboot_confirm: &'static str, + pub shutdown_confirm: &'static str, + pub confirm_yes: &'static str, + pub confirm_no: &'static str, + pub faillock_attempts_remaining: &'static str, + pub faillock_locked: &'static str, +} + +const STRINGS_DE: Strings = Strings { + password_placeholder: "Passwort", + unlock_button: "Entsperren", + reboot_tooltip: "Neustart", + shutdown_tooltip: "Herunterfahren", + fingerprint_prompt: "Fingerabdruck auflegen zum Entsperren", + fingerprint_success: "Fingerabdruck erkannt", + fingerprint_failed: "Fingerabdruck nicht erkannt", + auth_failed: "Authentifizierung fehlgeschlagen", + wrong_password: "Falsches Passwort", + reboot_failed: "Neustart fehlgeschlagen", + shutdown_failed: "Herunterfahren fehlgeschlagen", + reboot_confirm: "Wirklich neu starten?", + shutdown_confirm: "Wirklich herunterfahren?", + confirm_yes: "Ja", + confirm_no: "Abbrechen", + faillock_attempts_remaining: "Noch {n} Versuch(e) vor Kontosperrung!", + faillock_locked: "Konto ist möglicherweise gesperrt", +}; + +const STRINGS_EN: Strings = Strings { + password_placeholder: "Password", + unlock_button: "Unlock", + reboot_tooltip: "Reboot", + shutdown_tooltip: "Shut down", + fingerprint_prompt: "Place finger on reader to unlock", + fingerprint_success: "Fingerprint recognized", + fingerprint_failed: "Fingerprint not recognized", + auth_failed: "Authentication failed", + wrong_password: "Wrong password", + reboot_failed: "Reboot failed", + shutdown_failed: "Shutdown failed", + reboot_confirm: "Really reboot?", + shutdown_confirm: "Really shut down?", + confirm_yes: "Yes", + confirm_no: "Cancel", + faillock_attempts_remaining: "{n} attempt(s) remaining before lockout!", + faillock_locked: "Account may be locked", +}; + +fn parse_lang_prefix(lang: &str) -> String { + if lang.is_empty() || lang == "C" || lang == "POSIX" { return "en".to_string(); } + let prefix = lang.split('_').next().unwrap_or(lang).split('.').next().unwrap_or(lang).to_lowercase(); + if prefix.chars().all(|c| c.is_ascii_alphabetic()) && !prefix.is_empty() { prefix } else { "en".to_string() } +} + +fn read_lang_from_conf(path: &Path) -> Option { + let content = fs::read_to_string(path).ok()?; + for line in content.lines() { + if let Some(value) = line.strip_prefix("LANG=") { + let value = value.trim(); + if !value.is_empty() { return Some(value.to_string()); } + } + } + None +} + +pub fn detect_locale() -> String { + let lang = env::var("LANG").ok().filter(|s| !s.is_empty()) + .or_else(|| read_lang_from_conf(Path::new(DEFAULT_LOCALE_CONF))); + match lang { Some(l) => parse_lang_prefix(&l), None => "en".to_string() } +} + +pub fn load_strings(locale: Option<&str>) -> &'static Strings { + let locale = match locale { Some(l) => l.to_string(), None => detect_locale() }; + match locale.as_str() { "de" => &STRINGS_DE, _ => &STRINGS_EN } +} + +pub fn faillock_warning(attempt_count: u32, strings: &Strings) -> Option { + const MAX: u32 = 3; + if attempt_count >= MAX { return Some(strings.faillock_locked.to_string()); } + let remaining = MAX - attempt_count; + if remaining == 1 { return Some(strings.faillock_attempts_remaining.replace("{n}", &remaining.to_string())); } + None +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + #[test] fn parse_german() { assert_eq!(parse_lang_prefix("de_DE.UTF-8"), "de"); } + #[test] fn parse_english() { assert_eq!(parse_lang_prefix("en_US.UTF-8"), "en"); } + #[test] fn parse_c() { assert_eq!(parse_lang_prefix("C"), "en"); } + #[test] fn parse_empty() { assert_eq!(parse_lang_prefix(""), "en"); } + + #[test] fn read_conf() { + let dir = tempfile::tempdir().unwrap(); + let conf = dir.path().join("locale.conf"); + let mut f = fs::File::create(&conf).unwrap(); + writeln!(f, "LANG=de_DE.UTF-8").unwrap(); + assert_eq!(read_lang_from_conf(&conf), Some("de_DE.UTF-8".to_string())); + } + + #[test] fn strings_de() { let s = load_strings(Some("de")); assert_eq!(s.password_placeholder, "Passwort"); } + #[test] fn strings_en() { let s = load_strings(Some("en")); assert_eq!(s.password_placeholder, "Password"); } + #[test] fn strings_fallback() { let s = load_strings(Some("fr")); assert_eq!(s.password_placeholder, "Password"); } + + #[test] fn fingerprint_strings() { + let s = load_strings(Some("de")); + assert!(!s.fingerprint_prompt.is_empty()); + assert!(!s.fingerprint_success.is_empty()); + assert!(!s.fingerprint_failed.is_empty()); + } + + #[test] fn faillock_zero() { assert!(faillock_warning(0, load_strings(Some("en"))).is_none()); } + #[test] fn faillock_one() { assert!(faillock_warning(1, load_strings(Some("en"))).is_none()); } + #[test] fn faillock_two() { assert!(faillock_warning(2, load_strings(Some("en"))).is_some()); } + #[test] fn faillock_three() { assert_eq!(faillock_warning(3, load_strings(Some("en"))).unwrap(), "Account may be locked"); } +} diff --git a/src/lockscreen.rs b/src/lockscreen.rs new file mode 100644 index 0000000..fa7c8f8 --- /dev/null +++ b/src/lockscreen.rs @@ -0,0 +1,522 @@ +// ABOUTME: GTK4 lockscreen UI — avatar, password entry, fingerprint indicator, power buttons. +// ABOUTME: Connects PAM auth and fprintd listener, handles unlock flow. + +use gdk4 as gdk; +use gdk_pixbuf::Pixbuf; +use glib::clone; +use gtk4::prelude::*; +use gtk4::{self as gtk, gio}; +use std::cell::RefCell; +use std::path::Path; +use std::rc::Rc; + +use crate::auth; +use crate::config::Config; +use crate::fingerprint::FingerprintListener; +use crate::i18n::{faillock_warning, load_strings, Strings}; +use crate::power::{self, PowerError}; +use crate::users; + +const AVATAR_SIZE: i32 = 128; +const FAILLOCK_MAX_ATTEMPTS: u32 = 3; + +/// Shared mutable state for the lockscreen. +struct LockscreenState { + failed_attempts: u32, + fp_listener: FingerprintListener, +} + +/// Create a lockscreen window for a single monitor. +pub fn create_lockscreen_window( + bg_path: &Path, + config: &Config, + app: >k::Application, + unlock_callback: Rc, +) -> gtk::ApplicationWindow { + let window = gtk::ApplicationWindow::builder() + .application(app) + .build(); + window.add_css_class("lockscreen"); + + let strings = load_strings(None); + let user = match users::get_current_user() { + Some(u) => u, + None => { + log::error!("Failed to get current user"); + return window; + } + }; + + let mut fp_listener = FingerprintListener::new(); + let fp_available = config.fingerprint_enabled + && fp_listener.is_available(&user.username); + + let state = Rc::new(RefCell::new(LockscreenState { + failed_attempts: 0, + fp_listener, + })); + + // Root overlay for background + centered content + let overlay = gtk::Overlay::new(); + window.set_child(Some(&overlay)); + + // Background wallpaper + let background = create_background_picture(bg_path); + overlay.set_child(Some(&background)); + + // Centered vertical box + let main_box = gtk::Box::new(gtk::Orientation::Vertical, 0); + main_box.set_halign(gtk::Align::Center); + main_box.set_valign(gtk::Align::Center); + overlay.add_overlay(&main_box); + + // Login box + let login_box = gtk::Box::new(gtk::Orientation::Vertical, 8); + login_box.set_halign(gtk::Align::Center); + login_box.add_css_class("login-box"); + main_box.append(&login_box); + + // Avatar + let avatar_frame = gtk::Box::new(gtk::Orientation::Horizontal, 0); + avatar_frame.set_size_request(AVATAR_SIZE, AVATAR_SIZE); + avatar_frame.set_halign(gtk::Align::Center); + avatar_frame.set_overflow(gtk::Overflow::Hidden); + avatar_frame.add_css_class("avatar"); + let avatar_image = gtk::Image::new(); + avatar_image.set_pixel_size(AVATAR_SIZE); + avatar_frame.append(&avatar_image); + login_box.append(&avatar_frame); + + // Load avatar + let avatar_path = users::get_avatar_path(&user.home, &user.username); + if let Some(path) = avatar_path { + set_avatar_from_file(&avatar_image, &path); + } else { + set_default_avatar(&avatar_image, &window); + } + + // Username label + let username_label = gtk::Label::new(Some(&user.display_name)); + username_label.add_css_class("username-label"); + login_box.append(&username_label); + + // Password entry + let password_entry = gtk::PasswordEntry::builder() + .placeholder_text(strings.password_placeholder) + .show_peek_icon(true) + .hexpand(true) + .build(); + password_entry.add_css_class("password-entry"); + login_box.append(&password_entry); + + // Error label + let error_label = gtk::Label::new(None); + error_label.add_css_class("error-label"); + error_label.set_visible(false); + login_box.append(&error_label); + + // Fingerprint label + let fp_label = gtk::Label::new(None); + fp_label.add_css_class("fingerprint-label"); + if fp_available { + fp_label.set_text(strings.fingerprint_prompt); + fp_label.set_visible(true); + } else { + fp_label.set_visible(false); + } + login_box.append(&fp_label); + + // Confirm box area (for power confirm) + let confirm_area = gtk::Box::new(gtk::Orientation::Vertical, 0); + confirm_area.set_halign(gtk::Align::Center); + login_box.append(&confirm_area); + let confirm_box: Rc>> = Rc::new(RefCell::new(None)); + + // Power buttons (bottom right) + let power_box = gtk::Box::new(gtk::Orientation::Horizontal, 4); + power_box.set_halign(gtk::Align::End); + power_box.set_valign(gtk::Align::End); + power_box.set_hexpand(true); + power_box.set_vexpand(true); + power_box.set_margin_end(16); + power_box.set_margin_bottom(16); + + let reboot_btn = gtk::Button::new(); + reboot_btn.set_icon_name("system-reboot-symbolic"); + reboot_btn.add_css_class("power-button"); + reboot_btn.set_tooltip_text(Some(strings.reboot_tooltip)); + reboot_btn.connect_clicked(clone!( + #[weak] + confirm_area, + #[strong] + confirm_box, + #[weak] + error_label, + move |_| { + show_power_confirm( + strings.reboot_confirm, + power::reboot, + strings.reboot_failed, + strings, + &confirm_area, + &confirm_box, + &error_label, + ); + } + )); + power_box.append(&reboot_btn); + + let shutdown_btn = gtk::Button::new(); + shutdown_btn.set_icon_name("system-shutdown-symbolic"); + shutdown_btn.add_css_class("power-button"); + shutdown_btn.set_tooltip_text(Some(strings.shutdown_tooltip)); + shutdown_btn.connect_clicked(clone!( + #[weak] + confirm_area, + #[strong] + confirm_box, + #[weak] + error_label, + move |_| { + show_power_confirm( + strings.shutdown_confirm, + power::shutdown, + strings.shutdown_failed, + strings, + &confirm_area, + &confirm_box, + &error_label, + ); + } + )); + power_box.append(&shutdown_btn); + + overlay.add_overlay(&power_box); + + // Password entry "activate" handler + let username = user.username.clone(); + password_entry.connect_activate(clone!( + #[strong] + state, + #[strong] + unlock_callback, + #[weak] + error_label, + #[weak] + password_entry, + move |entry| { + let password = entry.text().to_string(); + if password.is_empty() { + return; + } + + entry.set_sensitive(false); + let username = username.clone(); + let unlock_cb = unlock_callback.clone(); + + glib::spawn_future_local(clone!( + #[strong] + state, + #[weak] + error_label, + #[weak] + password_entry, + async move { + let user = username.clone(); + let pass = password.clone(); + let result = gio::spawn_blocking(move || { + auth::authenticate(&user, &pass) + }).await; + + match result { + Ok(true) => { + state.borrow_mut().fp_listener.stop(); + unlock_cb(); + } + _ => { + let mut s = state.borrow_mut(); + s.failed_attempts += 1; + let count = s.failed_attempts; + let strings = load_strings(None); + password_entry.set_text(""); + + if count >= FAILLOCK_MAX_ATTEMPTS { + error_label.set_text(strings.faillock_locked); + error_label.set_visible(true); + password_entry.set_sensitive(false); + } else { + password_entry.set_sensitive(true); + password_entry.grab_focus(); + if let Some(warning) = faillock_warning(count, strings) { + error_label.set_text(&warning); + } else { + error_label.set_text(strings.wrong_password); + } + error_label.set_visible(true); + } + } + } + } + )); + } + )); + + // Keyboard handling — Escape clears password and error + let key_controller = gtk::EventControllerKey::new(); + key_controller.connect_key_pressed(clone!( + #[weak] + password_entry, + #[weak] + error_label, + #[upgrade_or] + glib::Propagation::Proceed, + move |_, keyval, _, _| { + if keyval == gdk::Key::Escape { + password_entry.set_text(""); + error_label.set_visible(false); + password_entry.grab_focus(); + glib::Propagation::Stop + } else { + glib::Propagation::Proceed + } + } + )); + window.add_controller(key_controller); + + // Start fingerprint listener + if fp_available { + let unlock_cb_fp = unlock_callback.clone(); + let fp_label_success = fp_label.clone(); + let fp_label_fail = fp_label.clone(); + + let on_success = move || { + let label = fp_label_success.clone(); + let cb = unlock_cb_fp.clone(); + glib::idle_add_local_once(move || { + label.set_text(load_strings(None).fingerprint_success); + label.add_css_class("success"); + cb(); + }); + }; + + let on_failure = move || { + let label = fp_label_fail.clone(); + glib::idle_add_local_once(clone!( + #[weak] + label, + move || { + let strings = load_strings(None); + label.set_text(strings.fingerprint_failed); + label.add_css_class("failed"); + // Reset after 2 seconds + glib::timeout_add_local_once( + std::time::Duration::from_secs(2), + clone!( + #[weak] + label, + move || { + label.set_text(load_strings(None).fingerprint_prompt); + label.remove_css_class("success"); + label.remove_css_class("failed"); + } + ), + ); + } + )); + }; + + state + .borrow_mut() + .fp_listener + .start(&user.username, on_success, on_failure); + } + + // Fade-in on map + window.connect_map(|w| { + glib::idle_add_local_once(clone!( + #[weak] + w, + move || { + w.add_css_class("visible"); + } + )); + }); + + // Focus password entry on realize + window.connect_realize(clone!( + #[weak] + password_entry, + move |_| { + glib::idle_add_local_once(move || { + password_entry.grab_focus(); + }); + } + )); + + window +} + +/// Create a Picture widget for the wallpaper background. +fn create_background_picture(bg_path: &Path) -> gtk::Picture { + let background = if bg_path.starts_with("/dev/moonarch/moonlock") { + gtk::Picture::for_resource(bg_path.to_str().unwrap_or("")) + } else { + gtk::Picture::for_filename(bg_path.to_str().unwrap_or("")) + }; + background.set_content_fit(gtk::ContentFit::Cover); + background.set_hexpand(true); + background.set_vexpand(true); + background +} + +/// Load an image file and set it as the avatar. +fn set_avatar_from_file(image: >k::Image, path: &Path) { + match Pixbuf::from_file_at_scale(path.to_str().unwrap_or(""), AVATAR_SIZE, AVATAR_SIZE, true) { + Ok(pixbuf) => { + let texture = gdk::Texture::for_pixbuf(&pixbuf); + image.set_paintable(Some(&texture)); + } + Err(_) => { + image.set_icon_name(Some("avatar-default-symbolic")); + } + } +} + +/// Load the default avatar SVG from GResources, tinted with the foreground color. +fn set_default_avatar(image: >k::Image, window: >k::ApplicationWindow) { + let resource_path = users::get_default_avatar_path(); + if let Ok(bytes) = + gio::resources_lookup_data(&resource_path, gio::ResourceLookupFlags::NONE) + { + let svg_text = String::from_utf8_lossy(&bytes); + let rgba = window.color(); + let fg_color = format!( + "#{:02x}{:02x}{:02x}", + (rgba.red() * 255.0) as u8, + (rgba.green() * 255.0) as u8, + (rgba.blue() * 255.0) as u8, + ); + let tinted = svg_text.replace("#PLACEHOLDER", &fg_color); + let svg_bytes = tinted.as_bytes(); + if let Ok(loader) = gdk_pixbuf::PixbufLoader::with_type("svg") { + loader.set_size(AVATAR_SIZE, AVATAR_SIZE); + if loader.write(svg_bytes).is_ok() { + let _ = loader.close(); + if let Some(pixbuf) = loader.pixbuf() { + let texture = gdk::Texture::for_pixbuf(&pixbuf); + image.set_paintable(Some(&texture)); + return; + } + } + } + } + image.set_icon_name(Some("avatar-default-symbolic")); +} + +/// Show inline power confirmation. +fn show_power_confirm( + message: &'static str, + action_fn: fn() -> Result<(), PowerError>, + error_message: &'static str, + strings: &'static Strings, + confirm_area: >k::Box, + confirm_box: &Rc>>, + error_label: >k::Label, +) { + dismiss_power_confirm(confirm_area, confirm_box); + + let new_box = gtk::Box::new(gtk::Orientation::Vertical, 8); + new_box.set_halign(gtk::Align::Center); + new_box.set_margin_top(16); + + let confirm_label = gtk::Label::new(Some(message)); + confirm_label.add_css_class("confirm-label"); + new_box.append(&confirm_label); + + let button_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); + button_row.set_halign(gtk::Align::Center); + + let yes_btn = gtk::Button::with_label(strings.confirm_yes); + yes_btn.add_css_class("confirm-yes"); + yes_btn.connect_clicked(clone!( + #[weak] + confirm_area, + #[strong] + confirm_box, + #[weak] + error_label, + move |_| { + dismiss_power_confirm(&confirm_area, &confirm_box); + execute_power_action(action_fn, error_message, &error_label); + } + )); + button_row.append(&yes_btn); + + let no_btn = gtk::Button::with_label(strings.confirm_no); + no_btn.add_css_class("confirm-no"); + no_btn.connect_clicked(clone!( + #[weak] + confirm_area, + #[strong] + confirm_box, + move |_| { + dismiss_power_confirm(&confirm_area, &confirm_box); + } + )); + button_row.append(&no_btn); + + new_box.append(&button_row); + confirm_area.append(&new_box); + *confirm_box.borrow_mut() = Some(new_box); + no_btn.grab_focus(); +} + +/// Remove the power confirmation prompt. +fn dismiss_power_confirm(confirm_area: >k::Box, confirm_box: &Rc>>) { + if let Some(box_widget) = confirm_box.borrow_mut().take() { + confirm_area.remove(&box_widget); + } +} + +/// Execute a power action in a background thread. +fn execute_power_action( + action_fn: fn() -> Result<(), PowerError>, + error_message: &'static str, + error_label: >k::Label, +) { + glib::spawn_future_local(clone!( + #[weak] + error_label, + async move { + let result = gio::spawn_blocking(move || action_fn()).await; + match result { + Ok(Ok(())) => {} + Ok(Err(e)) => { + log::error!("Power action failed: {e}"); + error_label.set_text(error_message); + error_label.set_visible(true); + } + Err(_) => { + log::error!("Power action panicked"); + error_label.set_text(error_message); + error_label.set_visible(true); + } + } + } + )); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn faillock_threshold() { + assert_eq!(FAILLOCK_MAX_ATTEMPTS, 3); + } + + #[test] + fn avatar_size_matches_css() { + assert_eq!(AVATAR_SIZE, 128); + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..f0989ea --- /dev/null +++ b/src/main.rs @@ -0,0 +1,164 @@ +// ABOUTME: Entry point for Moonlock — secure Wayland lockscreen. +// ABOUTME: Sets up GTK Application, ext-session-lock-v1, Panic-Hook, and multi-monitor windows. + +mod auth; +mod config; +mod fingerprint; +mod i18n; +mod lockscreen; +mod power; +mod users; + +use gdk4 as gdk; +use gtk4::prelude::*; +use gtk4::{self as gtk, gio}; +use gtk4_session_lock; +use std::path::PathBuf; +use std::rc::Rc; + +fn load_css(display: &gdk::Display) { + let css_provider = gtk::CssProvider::new(); + css_provider.load_from_resource("/dev/moonarch/moonlock/style.css"); + gtk::style_context_add_provider_for_display( + display, + &css_provider, + gtk::STYLE_PROVIDER_PRIORITY_APPLICATION, + ); +} + +fn activate(app: >k::Application) { + let display = match gdk::Display::default() { + Some(d) => d, + None => { + log::error!("No display available — cannot start lockscreen UI"); + return; + } + }; + + load_css(&display); + + let config = config::load_config(None); + let bg_path = config::resolve_background_path(&config); + + if gtk4_session_lock::is_supported() { + activate_with_session_lock(app, &display, &bg_path, &config); + } else { + log::warn!("ext-session-lock-v1 not supported — running in development mode"); + activate_without_lock(app, &bg_path, &config); + } +} + +fn activate_with_session_lock( + app: >k::Application, + display: &gdk::Display, + bg_path: &PathBuf, + config: &config::Config, +) { + let lock = gtk4_session_lock::Instance::new(); + lock.lock(); + + let monitors = display.monitors(); + + // Shared unlock callback — unlocks session and quits + let lock_clone = lock.clone(); + let app_clone = app.clone(); + let unlock_callback: Rc = Rc::new(move || { + lock_clone.unlock(); + app_clone.quit(); + }); + + let mut created_any = false; + for i in 0..monitors.n_items() { + if let Some(monitor) = monitors + .item(i) + .and_then(|obj| obj.downcast::().ok()) + { + let window = lockscreen::create_lockscreen_window( + bg_path, + config, + app, + unlock_callback.clone(), + ); + lock.assign_window_to_monitor(&window, &monitor); + window.present(); + created_any = true; + } + } + + if !created_any { + log::error!("No lockscreen windows created — screen stays locked (compositor policy)"); + } +} + +fn activate_without_lock( + app: >k::Application, + bg_path: &PathBuf, + config: &config::Config, +) { + let app_clone = app.clone(); + let unlock_callback: Rc = Rc::new(move || { + app_clone.quit(); + }); + + let window = lockscreen::create_lockscreen_window( + bg_path, + config, + app, + unlock_callback, + ); + window.set_default_size(800, 600); + window.present(); +} + +fn setup_logging() { + let mut builder = env_logger::Builder::from_default_env(); + builder.filter_level(log::LevelFilter::Info); + + let log_dir = PathBuf::from("/var/cache/moonlock"); + if log_dir.is_dir() { + let log_file = log_dir.join("moonlock.log"); + if let Ok(file) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&log_file) + { + builder.target(env_logger::Target::Pipe(Box::new(file))); + } + } + + builder.init(); +} + +fn install_panic_hook() { + // Install a panic hook BEFORE starting the app. + // On panic, we log but NEVER unlock. The compositor's ext-session-lock-v1 + // policy keeps the screen locked when the client crashes. + let default_hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + log::error!("PANIC — screen stays locked (compositor policy): {info}"); + default_hook(info); + })); +} + +fn main() { + setup_logging(); + + // Root check — moonlock should not run as root + if nix::unistd::getuid().is_root() { + log::error!("Moonlock should not run as root"); + std::process::exit(1); + } + + install_panic_hook(); + log::info!("Moonlock starting"); + + // Register compiled GResources + gio::resources_register_include!("moonlock.gresource").expect("Failed to register resources"); + + let app = gtk::Application::builder() + .application_id("dev.moonarch.moonlock") + .build(); + + app.connect_activate(activate); + app.run(); +} diff --git a/src/moonlock/__init__.py b/src/moonlock/__init__.py deleted file mode 100644 index c681af5..0000000 --- a/src/moonlock/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -# ABOUTME: Package init for moonlock — a secure Wayland lockscreen. -# ABOUTME: Uses ext-session-lock-v1, PAM auth and fprintd fingerprint support. diff --git a/src/moonlock/auth.py b/src/moonlock/auth.py deleted file mode 100644 index 7162977..0000000 --- a/src/moonlock/auth.py +++ /dev/null @@ -1,159 +0,0 @@ -# ABOUTME: PAM authentication via ctypes wrapper around libpam.so. -# ABOUTME: Provides authenticate(username, password) for the lockscreen. - -import ctypes -import ctypes.util -from ctypes import POINTER, Structure, c_char_p, c_int, c_void_p, CFUNCTYPE, pointer - -# PAM return codes -PAM_SUCCESS = 0 -PAM_AUTH_ERR = 7 -PAM_PROMPT_ECHO_OFF = 1 - - -class PamMessage(Structure): - """PAM message structure (pam_message).""" - - _fields_ = [ - ("msg_style", c_int), - ("msg", c_char_p), - ] - - -class PamResponse(Structure): - """PAM response structure (pam_response).""" - - # resp is c_void_p (not c_char_p) to preserve raw malloc'd pointers — - # ctypes auto-converts c_char_p returns to Python bytes, losing the pointer. - _fields_ = [ - ("resp", c_void_p), - ("resp_retcode", c_int), - ] - - -# PAM conversation callback type -PamConvFunc = CFUNCTYPE( - c_int, - c_int, - POINTER(POINTER(PamMessage)), - POINTER(POINTER(PamResponse)), - c_void_p, -) - - -class PamConv(Structure): - """PAM conversation structure (pam_conv).""" - - _fields_ = [ - ("conv", PamConvFunc), - ("appdata_ptr", c_void_p), - ] - - -_cached_libpam: ctypes.CDLL | None = None -_cached_libc: ctypes.CDLL | None = None - - -def _get_libpam() -> ctypes.CDLL: - """Load and return the libpam shared library (cached after first call).""" - global _cached_libpam - if _cached_libpam is None: - pam_path = ctypes.util.find_library("pam") - if not pam_path: - raise OSError("libpam not found") - _cached_libpam = ctypes.CDLL(pam_path) - return _cached_libpam - - -def _get_libc() -> ctypes.CDLL: - """Load and return the libc shared library (cached after first call).""" - global _cached_libc - if _cached_libc is None: - libc_path = ctypes.util.find_library("c") - if not libc_path: - raise OSError("libc not found") - libc = ctypes.CDLL(libc_path) - libc.calloc.restype = c_void_p - libc.calloc.argtypes = [ctypes.c_size_t, ctypes.c_size_t] - libc.strdup.restype = c_void_p - libc.strdup.argtypes = [c_char_p] - _cached_libc = libc - return _cached_libc - - -def _make_conv_func(password_bytes: bytearray) -> PamConvFunc: - """Create a PAM conversation callback that provides the password. - - Takes a bytearray (not str) so the caller can wipe it after use. - The callback creates a temporary bytes copy for strdup, then the - bytearray remains the single wipeable source of truth. - """ - libc = _get_libc() - - def _conv( - num_msg: int, - msg: POINTER(POINTER(PamMessage)), - resp: POINTER(POINTER(PamResponse)), - appdata_ptr: c_void_p, - ) -> int: - # PAM expects malloc'd memory — it will free() the responses and resp strings - resp_array = libc.calloc(num_msg, ctypes.sizeof(PamResponse)) - if not resp_array: - return PAM_AUTH_ERR - - resp_ptr = ctypes.cast(resp_array, POINTER(PamResponse)) - for i in range(num_msg): - # strdup allocates with malloc, which PAM can safely free() - resp_ptr[i].resp = libc.strdup(bytes(password_bytes)) - resp_ptr[i].resp_retcode = 0 - - resp[0] = resp_ptr - return PAM_SUCCESS - - return PamConvFunc(_conv) - - -def _wipe_bytes(data: bytes | bytearray) -> None: - """Overwrite sensitive bytes in memory with zeros.""" - if isinstance(data, bytearray): - for i in range(len(data)): - data[i] = 0 - - -def authenticate(username: str, password: str) -> bool: - """Authenticate a user via PAM. Returns True on success, False otherwise.""" - libpam = _get_libpam() - - # Use a mutable bytearray so we can wipe the password after use - password_bytes = bytearray(password.encode("utf-8")) - - # Set up conversation — pass bytearray, not the str - conv_func = _make_conv_func(password_bytes) - conv = PamConv(conv=conv_func, appdata_ptr=None) - - # PAM handle - handle = c_void_p() - - # Start PAM session - ret = libpam.pam_start( - b"moonlock", - username.encode("utf-8"), - pointer(conv), - pointer(handle), - ) - if ret != PAM_SUCCESS: - _wipe_bytes(password_bytes) - return False - - try: - # Authenticate - ret = libpam.pam_authenticate(handle, 0) - if ret != PAM_SUCCESS: - return False - - # Check account validity - ret = libpam.pam_acct_mgmt(handle, 0) - return ret == PAM_SUCCESS - finally: - libpam.pam_end(handle, ret) - _wipe_bytes(password_bytes) diff --git a/src/moonlock/config.py b/src/moonlock/config.py deleted file mode 100644 index c20fef7..0000000 --- a/src/moonlock/config.py +++ /dev/null @@ -1,62 +0,0 @@ -# ABOUTME: Configuration loading for the lockscreen. -# ABOUTME: Reads moonlock.toml for wallpaper and feature settings. - -import tomllib -from dataclasses import dataclass, field -from importlib.resources import files -from pathlib import Path - -MOONARCH_WALLPAPER = Path("/usr/share/moonarch/wallpaper.jpg") -PACKAGE_WALLPAPER = Path(str(files("moonlock") / "data" / "wallpaper.jpg")) - -DEFAULT_CONFIG_PATHS = [ - Path("/etc/moonlock/moonlock.toml"), - Path.home() / ".config" / "moonlock" / "moonlock.toml", -] - - -@dataclass(frozen=True) -class Config: - """Lockscreen configuration.""" - - background_path: str | None = None - fingerprint_enabled: bool = True - - -def load_config( - config_paths: list[Path] | None = None, -) -> Config: - """Load config from TOML file. Later paths override earlier ones.""" - if config_paths is None: - config_paths = DEFAULT_CONFIG_PATHS - - merged: dict = {} - for path in config_paths: - if path.exists(): - with open(path, "rb") as f: - data = tomllib.load(f) - merged.update(data) - - return Config( - background_path=merged.get("background_path"), - fingerprint_enabled=merged.get("fingerprint_enabled", True), - ) - - -def resolve_background_path(config: Config) -> Path: - """Resolve the wallpaper path using the fallback hierarchy. - - Priority: config background_path > Moonarch system default > package fallback. - """ - # User-configured path - if config.background_path: - path = Path(config.background_path) - if path.is_file(): - return path - - # Moonarch ecosystem default - if MOONARCH_WALLPAPER.is_file(): - return MOONARCH_WALLPAPER - - # Package fallback (always present) - return PACKAGE_WALLPAPER diff --git a/src/moonlock/fingerprint.py b/src/moonlock/fingerprint.py deleted file mode 100644 index 83a83f5..0000000 --- a/src/moonlock/fingerprint.py +++ /dev/null @@ -1,201 +0,0 @@ -# ABOUTME: fprintd D-Bus integration for fingerprint authentication. -# ABOUTME: Provides FingerprintListener that runs async in the GLib mainloop. - -import logging -from typing import Callable - -import gi -gi.require_version("Gio", "2.0") -from gi.repository import Gio, GLib - -logger = logging.getLogger(__name__) - -FPRINTD_BUS_NAME = "net.reactivated.Fprint" -FPRINTD_MANAGER_PATH = "/net/reactivated/Fprint/Manager" -FPRINTD_MANAGER_IFACE = "net.reactivated.Fprint.Manager" -FPRINTD_DEVICE_IFACE = "net.reactivated.Fprint.Device" - -# Maximum fingerprint verification attempts before disabling -_MAX_FP_ATTEMPTS = 10 - -# Retry-able statuses (finger not read properly, try again) -_RETRY_STATUSES = { - "verify-swipe-too-short", - "verify-finger-not-centered", - "verify-remove-and-retry", - "verify-retry-scan", -} - - -class FingerprintListener: - """Listens for fingerprint verification events via fprintd D-Bus.""" - - def __init__(self) -> None: - self._device_proxy: Gio.DBusProxy | None = None - self._device_path: str | None = None - self._signal_id: int | None = None - self._running: bool = False - self._failed_attempts: int = 0 - self._on_success: Callable[[], None] | None = None - self._on_failure: Callable[[], None] | None = None - - self._init_device() - - def _init_device(self) -> None: - """Connect to fprintd and get the default device. - - This uses synchronous D-Bus calls — call before creating GTK windows - to avoid blocking the mainloop. - """ - try: - manager = Gio.DBusProxy.new_for_bus_sync( - Gio.BusType.SYSTEM, - Gio.DBusProxyFlags.NONE, - None, - FPRINTD_BUS_NAME, - FPRINTD_MANAGER_PATH, - FPRINTD_MANAGER_IFACE, - None, - ) - result = manager.GetDefaultDevice() - if result: - self._device_path = result - self._device_proxy = Gio.DBusProxy.new_for_bus_sync( - Gio.BusType.SYSTEM, - Gio.DBusProxyFlags.NONE, - None, - FPRINTD_BUS_NAME, - self._device_path, - FPRINTD_DEVICE_IFACE, - None, - ) - except GLib.Error: - self._device_proxy = None - self._device_path = None - - def is_available(self, username: str) -> bool: - """Check if fprintd is running and the user has enrolled fingerprints.""" - if not self._device_proxy: - return False - try: - result = self._device_proxy.ListEnrolledFingers("(s)", username) - return bool(result) - except GLib.Error: - return False - - def start( - self, - username: str, - on_success: Callable[[], None], - on_failure: Callable[[], None], - ) -> None: - """Start listening for fingerprint verification.""" - if not self._device_proxy: - return - - self._on_success = on_success - self._on_failure = on_failure - - try: - self._device_proxy.Claim("(s)", username) - except GLib.Error as e: - logger.error("Failed to claim fingerprint device: %s", e.message) - return - - # Connect to the VerifyStatus signal - self._signal_id = self._device_proxy.connect( - "g-signal", self._on_signal - ) - - try: - self._device_proxy.VerifyStart("(s)", "any") - except GLib.Error as e: - logger.error("Failed to start fingerprint verification: %s", e.message) - self._device_proxy.disconnect(self._signal_id) - self._signal_id = None - try: - self._device_proxy.Release() - except GLib.Error: - pass - return - - self._running = True - - def stop(self) -> None: - """Stop listening and release the device.""" - if not self._running: - return - - self._running = False - - if self._device_proxy: - if self._signal_id is not None: - self._device_proxy.disconnect(self._signal_id) - self._signal_id = None - - try: - self._device_proxy.VerifyStop() - except GLib.Error: - pass - - try: - self._device_proxy.Release() - except GLib.Error: - pass - - def _on_signal( - self, - proxy: Gio.DBusProxy, - sender_name: str | None, - signal_name: str, - parameters: GLib.Variant, - ) -> None: - """Handle D-Bus signals from the fprintd device.""" - if signal_name != "VerifyStatus": - return - - # Validate signal origin — only accept signals from fprintd - if sender_name and not sender_name.startswith(FPRINTD_BUS_NAME): - expected_sender = proxy.get_name_owner() - if sender_name != expected_sender: - logger.warning("Ignoring VerifyStatus from unexpected sender: %s", sender_name) - return - - status = parameters[0] - done = parameters[1] - self._on_verify_status(status, done) - - def _on_verify_status(self, status: str, done: bool) -> None: - """Process a VerifyStatus signal from fprintd.""" - if not self._running: - return - - if status == "verify-match": - if self._on_success: - self._on_success() - return - - if status in _RETRY_STATUSES: - # Retry — finger wasn't read properly - if done and self._running and self._device_proxy: - try: - self._device_proxy.VerifyStart("(s)", "any") - except GLib.Error as e: - logger.error("Failed to restart fingerprint verification: %s", e.message) - return - - if status == "verify-no-match": - self._failed_attempts += 1 - if self._on_failure: - self._on_failure() - if self._failed_attempts >= _MAX_FP_ATTEMPTS: - logger.warning("Fingerprint max attempts (%d) reached, stopping listener", _MAX_FP_ATTEMPTS) - self.stop() - return - # Restart verification for another attempt - if done and self._running and self._device_proxy: - try: - self._device_proxy.VerifyStart("(s)", "any") - except GLib.Error as e: - logger.error("Failed to restart fingerprint verification: %s", e.message) - return diff --git a/src/moonlock/i18n.py b/src/moonlock/i18n.py deleted file mode 100644 index b1ff251..0000000 --- a/src/moonlock/i18n.py +++ /dev/null @@ -1,111 +0,0 @@ -# ABOUTME: Locale detection and string lookup for the lockscreen UI. -# ABOUTME: Reads system locale (LANG or /etc/locale.conf) and provides DE or EN strings. - -import os -from dataclasses import dataclass -from pathlib import Path - -DEFAULT_LOCALE_CONF = Path("/etc/locale.conf") - - -@dataclass(frozen=True) -class Strings: - """All user-visible strings for the lockscreen UI.""" - - # UI labels - password_placeholder: str - unlock_button: str - reboot_tooltip: str - shutdown_tooltip: str - - # Fingerprint - fingerprint_prompt: str - fingerprint_success: str - fingerprint_failed: str - - # Error messages - auth_failed: str - wrong_password: str - reboot_failed: str - shutdown_failed: str - - # Power confirmation - reboot_confirm: str - shutdown_confirm: str - confirm_yes: str - confirm_no: str - - # Templates (use .format()) - faillock_attempts_remaining: str - faillock_locked: str - - -_STRINGS_DE = Strings( - password_placeholder="Passwort", - unlock_button="Entsperren", - reboot_tooltip="Neustart", - shutdown_tooltip="Herunterfahren", - fingerprint_prompt="Fingerabdruck auflegen zum Entsperren", - fingerprint_success="Fingerabdruck erkannt", - fingerprint_failed="Fingerabdruck nicht erkannt", - auth_failed="Authentifizierung fehlgeschlagen", - wrong_password="Falsches Passwort", - reboot_failed="Neustart fehlgeschlagen", - shutdown_failed="Herunterfahren fehlgeschlagen", - reboot_confirm="Wirklich neu starten?", - shutdown_confirm="Wirklich herunterfahren?", - confirm_yes="Ja", - confirm_no="Abbrechen", - faillock_attempts_remaining="Noch {n} Versuch(e) vor Kontosperrung!", - faillock_locked="Konto ist möglicherweise gesperrt", -) - -_STRINGS_EN = Strings( - password_placeholder="Password", - unlock_button="Unlock", - reboot_tooltip="Reboot", - shutdown_tooltip="Shut down", - fingerprint_prompt="Place finger on reader to unlock", - fingerprint_success="Fingerprint recognized", - fingerprint_failed="Fingerprint not recognized", - auth_failed="Authentication failed", - wrong_password="Wrong password", - reboot_failed="Reboot failed", - shutdown_failed="Shutdown failed", - reboot_confirm="Really reboot?", - shutdown_confirm="Really shut down?", - confirm_yes="Yes", - confirm_no="Cancel", - faillock_attempts_remaining="{n} attempt(s) remaining before lockout!", - faillock_locked="Account may be locked", -) - -_LOCALE_MAP: dict[str, Strings] = { - "de": _STRINGS_DE, - "en": _STRINGS_EN, -} - - -def detect_locale(locale_conf_path: Path = DEFAULT_LOCALE_CONF) -> str: - """Determine the system language from LANG env var or /etc/locale.conf.""" - lang = os.environ.get("LANG") - - if not lang and locale_conf_path.exists(): - for line in locale_conf_path.read_text().splitlines(): - if line.startswith("LANG="): - lang = line.split("=", 1)[1].strip() - break - - if not lang or lang in ("C", "POSIX"): - return "en" - - # Extract language prefix: "de_DE.UTF-8" → "de" - lang = lang.split("_")[0].split(".")[0] - return lang - - -def load_strings(locale: str | None = None) -> Strings: - """Return the string table for the given locale, defaulting to English.""" - if locale is None: - locale = detect_locale() - return _LOCALE_MAP.get(locale, _STRINGS_EN) diff --git a/src/moonlock/lockscreen.py b/src/moonlock/lockscreen.py deleted file mode 100644 index ca34c47..0000000 --- a/src/moonlock/lockscreen.py +++ /dev/null @@ -1,349 +0,0 @@ -# ABOUTME: GTK4 lockscreen UI — avatar, password entry, fingerprint indicator, power buttons. -# ABOUTME: Connects PAM auth and fprintd listener, handles unlock flow. - -import gi -gi.require_version("Gtk", "4.0") -gi.require_version("Gdk", "4.0") -from gi.repository import Gtk, Gdk, GdkPixbuf, GLib - -import logging -from collections.abc import Callable -from pathlib import Path - -logger = logging.getLogger(__name__) - -from moonlock.auth import authenticate -from moonlock.config import Config, load_config, resolve_background_path -from moonlock.fingerprint import FingerprintListener -from moonlock.i18n import Strings, load_strings -from moonlock.users import get_current_user, get_avatar_path, get_default_avatar_path, User -from moonlock import power - -# UI-only attempt counter — the real brute-force protection is pam_faillock -# in the system-auth PAM stack, which persists across process restarts. -FAILLOCK_MAX_ATTEMPTS = 3 -AVATAR_SIZE = 128 - - -class LockscreenWindow(Gtk.ApplicationWindow): - """Fullscreen lockscreen window with password and fingerprint auth.""" - - def __init__(self, application: Gtk.Application, unlock_callback: Callable[[], None] | None = None, - config: Config | None = None, - fingerprint_listener: FingerprintListener | None = None, - wallpaper_path: Path | None = None) -> None: - super().__init__(application=application) - self.add_css_class("lockscreen") - - self._config = config or load_config() - self._strings = load_strings() - self._user = get_current_user() - self._failed_attempts = 0 - self._unlock_callback = unlock_callback - self._wallpaper_path = wallpaper_path or resolve_background_path(self._config) - - # Fingerprint listener (shared across windows to avoid multiple device claims) - self._fp_listener = fingerprint_listener or FingerprintListener() - self._fp_available = ( - self._config.fingerprint_enabled - and self._fp_listener.is_available(self._user.username) - ) - - self._build_ui() - self._setup_keyboard() - self._password_entry.grab_focus() - - # Start fingerprint listener if available (only once across shared instances) - if self._fp_available and not self._fp_listener._running: - self._fp_listener.start( - self._user.username, - on_success=self._on_fingerprint_success, - on_failure=self._on_fingerprint_failure, - ) - - def _build_ui(self) -> None: - """Build the lockscreen layout.""" - # Main overlay for background + centered content - overlay = Gtk.Overlay() - self.set_child(overlay) - - # Background wallpaper (path resolved once, shared across monitors) - background = Gtk.Picture.new_for_filename(str(self._wallpaper_path)) - background.set_content_fit(Gtk.ContentFit.COVER) - background.set_hexpand(True) - background.set_vexpand(True) - overlay.set_child(background) - - # Centered vertical box - main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) - main_box.set_halign(Gtk.Align.CENTER) - main_box.set_valign(Gtk.Align.CENTER) - overlay.add_overlay(main_box) - - # Login box (centered card) - self._login_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) - self._login_box.set_halign(Gtk.Align.CENTER) - self._login_box.add_css_class("login-box") - main_box.append(self._login_box) - - # Avatar — wrapped in a clipping frame for round shape - avatar_frame = Gtk.Box() - avatar_frame.set_size_request(AVATAR_SIZE, AVATAR_SIZE) - avatar_frame.set_halign(Gtk.Align.CENTER) - avatar_frame.set_overflow(Gtk.Overflow.HIDDEN) - avatar_frame.add_css_class("avatar") - self._avatar_image = Gtk.Image() - self._avatar_image.set_pixel_size(AVATAR_SIZE) - avatar_frame.append(self._avatar_image) - self._login_box.append(avatar_frame) - - avatar_path = get_avatar_path(self._user.home, self._user.username) - if avatar_path: - self._set_avatar_from_file(avatar_path) - else: - self._set_default_avatar() - - # Username label - username_label = Gtk.Label(label=self._user.display_name) - username_label.add_css_class("username-label") - self._login_box.append(username_label) - - # Password entry - self._password_entry = Gtk.PasswordEntry() - self._password_entry.set_property("placeholder-text", self._strings.password_placeholder) - self._password_entry.set_property("show-peek-icon", True) - self._password_entry.add_css_class("password-entry") - self._password_entry.connect("activate", self._on_password_submit) - self._login_box.append(self._password_entry) - - # Error label - self._error_label = Gtk.Label() - self._error_label.add_css_class("error-label") - self._error_label.set_visible(False) - self._login_box.append(self._error_label) - - # Fingerprint status label - self._fp_label = Gtk.Label() - self._fp_label.add_css_class("fingerprint-label") - if self._fp_available: - self._fp_label.set_text(self._strings.fingerprint_prompt) - self._fp_label.set_visible(True) - else: - self._fp_label.set_visible(False) - self._login_box.append(self._fp_label) - - # Power buttons (bottom right) - power_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4) - power_box.set_halign(Gtk.Align.END) - power_box.set_valign(Gtk.Align.END) - power_box.set_hexpand(True) - power_box.set_vexpand(True) - power_box.set_margin_end(16) - power_box.set_margin_bottom(16) - overlay.add_overlay(power_box) - - reboot_btn = Gtk.Button(icon_name="system-reboot-symbolic") - reboot_btn.add_css_class("power-button") - reboot_btn.set_tooltip_text(self._strings.reboot_tooltip) - reboot_btn.connect("clicked", lambda _: self._on_power_action(power.reboot)) - power_box.append(reboot_btn) - - shutdown_btn = Gtk.Button(icon_name="system-shutdown-symbolic") - shutdown_btn.add_css_class("power-button") - shutdown_btn.set_tooltip_text(self._strings.shutdown_tooltip) - shutdown_btn.connect("clicked", lambda _: self._on_power_action(power.shutdown)) - power_box.append(shutdown_btn) - - def _setup_keyboard(self) -> None: - """Set up keyboard event handling.""" - controller = Gtk.EventControllerKey() - controller.connect("key-pressed", self._on_key_pressed) - self.add_controller(controller) - - def _on_key_pressed(self, controller: Gtk.EventControllerKey, keyval: int, - keycode: int, state: Gdk.ModifierType) -> bool: - """Handle key presses — Escape clears the password field.""" - if keyval == Gdk.KEY_Escape: - self._password_entry.set_text("") - self._error_label.set_visible(False) - self._password_entry.grab_focus() - return True - return False - - def _on_password_submit(self, entry: Gtk.PasswordEntry) -> None: - """Handle password submission via Enter key.""" - password = entry.get_text() - if not password: - return - - # Run PAM auth in a thread to avoid blocking the UI - entry.set_sensitive(False) - - def _do_auth() -> bool: - return authenticate(self._user.username, password) - - def _on_auth_done(result: bool) -> None: - if result: - self._unlock() - return - - self._failed_attempts += 1 - entry.set_text("") - - if self._failed_attempts >= FAILLOCK_MAX_ATTEMPTS: - # Permanently disable entry after max failed attempts - self._show_error(self._strings.faillock_locked) - entry.set_sensitive(False) - else: - entry.set_sensitive(True) - entry.grab_focus() - if self._failed_attempts >= FAILLOCK_MAX_ATTEMPTS - 1: - remaining = FAILLOCK_MAX_ATTEMPTS - self._failed_attempts - self._show_error( - self._strings.faillock_attempts_remaining.format(n=remaining) - ) - else: - self._show_error(self._strings.wrong_password) - - # Use GLib thread pool to avoid blocking GTK mainloop - def _auth_thread() -> bool: - try: - result = _do_auth() - except Exception: - result = False - GLib.idle_add(_on_auth_done, result) - return GLib.SOURCE_REMOVE - - GLib.Thread.new("pam-auth", _auth_thread) - - def _on_fingerprint_success(self) -> None: - """Called when fingerprint verification succeeds.""" - def _handle_success(): - self._fp_label.set_text(self._strings.fingerprint_success) - self._fp_label.add_css_class("success") - self._unlock() - return GLib.SOURCE_REMOVE - GLib.idle_add(_handle_success) - - def _on_fingerprint_failure(self) -> None: - """Called when fingerprint verification fails (no match).""" - GLib.idle_add(self._fp_label.set_text, self._strings.fingerprint_failed) - GLib.idle_add(self._fp_label.add_css_class, "failed") - # Reset label after 2 seconds - GLib.timeout_add( - 2000, - self._reset_fp_label, - ) - - def _reset_fp_label(self) -> bool: - """Reset fingerprint label to prompt state.""" - self._fp_label.set_text(self._strings.fingerprint_prompt) - self._fp_label.remove_css_class("success") - self._fp_label.remove_css_class("failed") - return GLib.SOURCE_REMOVE - - def _get_foreground_color(self) -> str: - """Get the current GTK theme foreground color as a hex string.""" - rgba = self.get_color() - r = int(rgba.red * 255) - g = int(rgba.green * 255) - b = int(rgba.blue * 255) - return f"#{r:02x}{g:02x}{b:02x}" - - def _set_default_avatar(self) -> None: - """Load the default avatar SVG, tinted with the GTK foreground color.""" - try: - default_path = get_default_avatar_path() - svg_text = default_path.read_text() - fg_color = self._get_foreground_color() - svg_text = svg_text.replace("#PLACEHOLDER", fg_color) - svg_bytes = svg_text.encode("utf-8") - loader = GdkPixbuf.PixbufLoader.new_with_type("svg") - loader.set_size(AVATAR_SIZE, AVATAR_SIZE) - loader.write(svg_bytes) - loader.close() - pixbuf = loader.get_pixbuf() - if pixbuf: - self._avatar_image.set_from_pixbuf(pixbuf) - return - except (GLib.Error, OSError): - pass - self._avatar_image.set_from_icon_name("avatar-default-symbolic") - - def _set_avatar_from_file(self, path: Path) -> None: - """Load an image file and set it as the avatar, scaled to AVATAR_SIZE.""" - try: - pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale( - str(path), AVATAR_SIZE, AVATAR_SIZE, True - ) - self._avatar_image.set_from_pixbuf(pixbuf) - except GLib.Error: - self._set_default_avatar() - - def _show_error(self, message: str) -> None: - """Display an error message.""" - self._error_label.set_text(message) - self._error_label.set_visible(True) - - def _unlock(self) -> None: - """Unlock the screen after successful authentication.""" - # Stop fingerprint listener - self._fp_listener.stop() - - if self._unlock_callback: - self._unlock_callback() - - def _on_power_action(self, action: Callable[[], None]) -> None: - """Request a power action with confirmation.""" - confirm_msg = ( - self._strings.reboot_confirm - if action == power.reboot - else self._strings.shutdown_confirm - ) - self._show_power_confirm(confirm_msg, action) - - def _show_power_confirm(self, message: str, action: Callable[[], None]) -> None: - """Show inline confirmation below the login box.""" - self._confirm_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) - self._confirm_box.set_halign(Gtk.Align.CENTER) - self._confirm_box.set_margin_top(16) - - confirm_label = Gtk.Label(label=message) - confirm_label.add_css_class("confirm-label") - self._confirm_box.append(confirm_label) - - button_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) - button_box.set_halign(Gtk.Align.CENTER) - - yes_btn = Gtk.Button(label=self._strings.confirm_yes) - yes_btn.add_css_class("confirm-yes") - yes_btn.connect("clicked", lambda _: self._execute_power_action(action)) - button_box.append(yes_btn) - - no_btn = Gtk.Button(label=self._strings.confirm_no) - no_btn.add_css_class("confirm-no") - no_btn.connect("clicked", lambda _: self._dismiss_power_confirm()) - button_box.append(no_btn) - - self._confirm_box.append(button_box) - self._login_box.append(self._confirm_box) - - def _execute_power_action(self, action: Callable[[], None]) -> None: - """Execute the confirmed power action.""" - self._dismiss_power_confirm() - try: - action() - except Exception: - logger.exception("Power action failed") - error_msg = ( - self._strings.reboot_failed - if action == power.reboot - else self._strings.shutdown_failed - ) - self._show_error(error_msg) - - def _dismiss_power_confirm(self) -> None: - """Remove the confirmation prompt.""" - if hasattr(self, "_confirm_box"): - self._login_box.remove(self._confirm_box) - del self._confirm_box diff --git a/src/moonlock/main.py b/src/moonlock/main.py deleted file mode 100644 index 458b89d..0000000 --- a/src/moonlock/main.py +++ /dev/null @@ -1,179 +0,0 @@ -# ABOUTME: Entry point for Moonlock — sets up GTK Application and ext-session-lock-v1. -# ABOUTME: Handles CLI invocation, session locking, and multi-monitor support. - -import logging -import os -import sys -from importlib.resources import files -from pathlib import Path - -# gtk4-layer-shell must be loaded before libwayland-client. -# Only allow our own library in LD_PRELOAD — discard anything inherited from the environment. -_LAYER_SHELL_LIB = "/usr/lib/libgtk4-layer-shell.so" -_existing_preload = os.environ.get("LD_PRELOAD", "") -_is_testing = "pytest" in sys.modules or "unittest" in sys.modules -if ( - not _is_testing - and _LAYER_SHELL_LIB not in _existing_preload - and os.path.exists(_LAYER_SHELL_LIB) -): - os.environ["LD_PRELOAD"] = _LAYER_SHELL_LIB - os.execvp(sys.executable, [sys.executable, "-m", "moonlock.main"] + sys.argv[1:]) - -import gi -gi.require_version("Gtk", "4.0") -gi.require_version("Gdk", "4.0") -from gi.repository import Gtk, Gdk - -from moonlock.config import load_config -from moonlock.fingerprint import FingerprintListener -from moonlock.lockscreen import LockscreenWindow - -logger = logging.getLogger(__name__) - -# ext-session-lock-v1 via gtk4-layer-shell -try: - gi.require_version("Gtk4SessionLock", "1.0") - from gi.repository import Gtk4SessionLock - HAS_SESSION_LOCK = True -except (ValueError, ImportError): - HAS_SESSION_LOCK = False - -_LOG_DIR = Path("/var/cache/moonlock") -_LOG_FILE = _LOG_DIR / "moonlock.log" - - -def _setup_logging() -> None: - """Configure logging to stderr and optionally to a log file.""" - root = logging.getLogger() - root.setLevel(logging.INFO) - - formatter = logging.Formatter( - "%(asctime)s %(levelname)s %(name)s: %(message)s" - ) - - stderr_handler = logging.StreamHandler(sys.stderr) - stderr_handler.setLevel(logging.INFO) - stderr_handler.setFormatter(formatter) - root.addHandler(stderr_handler) - - if _LOG_DIR.is_dir(): - try: - file_handler = logging.FileHandler(_LOG_FILE) - file_handler.setLevel(logging.INFO) - file_handler.setFormatter(formatter) - root.addHandler(file_handler) - except PermissionError: - logger.warning("Cannot write to %s", _LOG_FILE) - - -class MoonlockApp(Gtk.Application): - """GTK Application for the Moonlock lockscreen.""" - - def __init__(self) -> None: - super().__init__(application_id="dev.moonarch.moonlock") - self._lock_instance = None - self._windows: list[Gtk.Window] = [] - self._config = load_config() - - def do_activate(self) -> None: - """Create the lockscreen and lock the session.""" - self._load_css() - - if HAS_SESSION_LOCK and Gtk4SessionLock.is_supported(): - self._activate_with_session_lock() - else: - # Fallback for development/testing without Wayland - self._activate_without_lock() - - def _activate_with_session_lock(self) -> None: - """Lock the session using ext-session-lock-v1 protocol.""" - # Init fingerprint D-Bus before locking — sync D-Bus calls would block - # the GTK mainloop if done after lock when the UI needs to be responsive. - fp_listener = FingerprintListener() - - # Resolve wallpaper once, share across all monitors - from moonlock.config import resolve_background_path - wallpaper_path = resolve_background_path(self._config) - - self._lock_instance = Gtk4SessionLock.Instance.new() - self._lock_instance.lock() - - display = Gdk.Display.get_default() - monitors = display.get_monitors() - - for i in range(monitors.get_n_items()): - monitor = monitors.get_item(i) - try: - window = LockscreenWindow( - application=self, - unlock_callback=self._unlock, - config=self._config, - fingerprint_listener=fp_listener, - wallpaper_path=wallpaper_path, - ) - self._lock_instance.assign_window_to_monitor(window, monitor) - window.present() - self._windows.append(window) - except Exception: - logger.exception("Failed to create lockscreen window for monitor %d", i) - - if not self._windows: - logger.critical("No lockscreen windows created — screen stays locked (compositor policy)") - - def _activate_without_lock(self) -> None: - """Fallback for development — no session lock, just a window.""" - window = LockscreenWindow( - application=self, - unlock_callback=self._unlock, - ) - window.set_default_size(800, 600) - window.present() - self._windows.append(window) - - def _unlock(self) -> None: - """Unlock the session and exit.""" - if self._lock_instance: - self._lock_instance.unlock() - self.quit() - - def _load_css(self) -> None: - """Load the CSS stylesheet for the lockscreen.""" - try: - css_provider = Gtk.CssProvider() - css_path = files("moonlock") / "style.css" - css_provider.load_from_path(str(css_path)) - Gtk.StyleContext.add_provider_for_display( - Gdk.Display.get_default(), - css_provider, - Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION, - ) - except Exception: - logger.exception("Failed to load CSS stylesheet") - - -def _install_excepthook() -> None: - """Install a global exception handler that logs crashes without unlocking.""" - sys.excepthook = lambda exc_type, exc_value, exc_tb: ( - logger.critical("Unhandled exception — screen stays locked (compositor policy)", - exc_info=(exc_type, exc_value, exc_tb)), - sys.__excepthook__(exc_type, exc_value, exc_tb), - ) - - -def main() -> None: - """Run the Moonlock application.""" - _setup_logging() - - if os.getuid() == 0: - logger.critical("Moonlock should not run as root") - sys.exit(1) - - logger.info("Moonlock starting") - app = MoonlockApp() - _install_excepthook() - app.run(sys.argv) - - -if __name__ == "__main__": - main() diff --git a/src/moonlock/power.py b/src/moonlock/power.py deleted file mode 100644 index 4798116..0000000 --- a/src/moonlock/power.py +++ /dev/null @@ -1,14 +0,0 @@ -# ABOUTME: Power actions — reboot and shutdown via loginctl. -# ABOUTME: Simple wrappers around system commands for the lockscreen UI. - -import subprocess - - -def reboot() -> None: - """Reboot the system via loginctl.""" - subprocess.run(["loginctl", "reboot"], check=True) - - -def shutdown() -> None: - """Shut down the system via loginctl.""" - subprocess.run(["loginctl", "poweroff"], check=True) diff --git a/src/moonlock/users.py b/src/moonlock/users.py deleted file mode 100644 index eba7d5c..0000000 --- a/src/moonlock/users.py +++ /dev/null @@ -1,65 +0,0 @@ -# ABOUTME: Current user detection and avatar loading for the lockscreen. -# ABOUTME: Retrieves user info from the system (pwd, AccountsService, ~/.face). - -import os -import pwd -from dataclasses import dataclass -from importlib.resources import files -from pathlib import Path - -DEFAULT_ACCOUNTSSERVICE_DIR = Path("/var/lib/AccountsService/icons") - - -@dataclass(frozen=True) -class User: - """Represents the current user for the lockscreen.""" - - username: str - display_name: str - home: Path - uid: int - - -def get_current_user() -> User: - """Get the currently logged-in user's info from the system.""" - # Use getuid() instead of getlogin() — getlogin() fails without a controlling - # terminal (systemd units, display-manager-started sessions). - pw = pwd.getpwuid(os.getuid()) - - gecos = pw.pw_gecos - # GECOS field may contain comma-separated values; first field is the full name - display_name = gecos.split(",")[0] if gecos else pw.pw_name - if not display_name: - display_name = pw.pw_name - - return User( - username=pw.pw_name, - display_name=display_name, - home=Path(pw.pw_dir), - uid=pw.pw_uid, - ) - - -def get_avatar_path( - home: Path, - username: str | None = None, - accountsservice_dir: Path = DEFAULT_ACCOUNTSSERVICE_DIR, -) -> Path | None: - """Find the user's avatar image, checking ~/.face then AccountsService.""" - # ~/.face takes priority - face = home / ".face" - if face.exists(): - return face - - # AccountsService icon - if username and accountsservice_dir.exists(): - icon = accountsservice_dir / username - if icon.exists(): - return icon - - return None - - -def get_default_avatar_path() -> Path: - """Return the path to the package default avatar SVG.""" - return Path(str(files("moonlock") / "data" / "default-avatar.svg")) diff --git a/src/power.rs b/src/power.rs new file mode 100644 index 0000000..2959686 --- /dev/null +++ b/src/power.rs @@ -0,0 +1,52 @@ +// ABOUTME: Power actions — reboot and shutdown via loginctl. +// ABOUTME: Wrappers around system commands for the lockscreen UI. + +use std::fmt; +use std::process::Command; + +#[derive(Debug)] +pub enum PowerError { + CommandFailed { action: &'static str, message: String }, + Timeout { action: &'static str }, +} + +impl fmt::Display for PowerError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + PowerError::CommandFailed { action, message } => write!(f, "{action} failed: {message}"), + PowerError::Timeout { action } => write!(f, "{action} timed out"), + } + } +} + +impl std::error::Error for PowerError {} + +fn run_command(action: &'static str, program: &str, args: &[&str]) -> Result<(), PowerError> { + let child = Command::new(program) + .args(args) + .spawn() + .map_err(|e| PowerError::CommandFailed { action, message: e.to_string() })?; + let output = child.wait_with_output() + .map_err(|e| PowerError::CommandFailed { action, message: e.to_string() })?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(PowerError::CommandFailed { + action, message: format!("exit code {}: {}", output.status, stderr.trim()), + }); + } + Ok(()) +} + +pub fn reboot() -> Result<(), PowerError> { run_command("reboot", "/usr/bin/loginctl", &["reboot"]) } +pub fn shutdown() -> Result<(), PowerError> { run_command("shutdown", "/usr/bin/loginctl", &["poweroff"]) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] fn power_error_display() { assert_eq!(PowerError::CommandFailed { action: "reboot", message: "fail".into() }.to_string(), "reboot failed: fail"); } + #[test] fn timeout_display() { assert_eq!(PowerError::Timeout { action: "shutdown" }.to_string(), "shutdown timed out"); } + #[test] fn missing_binary() { assert!(run_command("test", "nonexistent-xyz", &[]).is_err()); } + #[test] fn nonzero_exit() { assert!(run_command("test", "false", &[]).is_err()); } + #[test] fn success() { assert!(run_command("test", "true", &[]).is_ok()); } +} diff --git a/src/users.rs b/src/users.rs new file mode 100644 index 0000000..17b4a7b --- /dev/null +++ b/src/users.rs @@ -0,0 +1,93 @@ +// ABOUTME: Current user detection and avatar loading for the lockscreen. +// ABOUTME: Retrieves user info from the system (nix getuid, AccountsService, ~/.face). + +use nix::unistd::{getuid, User as NixUser}; +use std::path::{Path, PathBuf}; + +const DEFAULT_ACCOUNTSSERVICE_DIR: &str = "/var/lib/AccountsService/icons"; +const GRESOURCE_PREFIX: &str = "/dev/moonarch/moonlock"; + +#[derive(Debug, Clone)] +pub struct User { + pub username: String, + pub display_name: String, + pub home: PathBuf, + pub uid: u32, +} + +pub fn get_current_user() -> Option { + let uid = getuid(); + let nix_user = NixUser::from_uid(uid).ok()??; + let gecos = nix_user.gecos.to_str().unwrap_or("").to_string(); + let display_name = if !gecos.is_empty() { + let first = gecos.split(',').next().unwrap_or(""); + if first.is_empty() { nix_user.name.clone() } else { first.to_string() } + } else { nix_user.name.clone() }; + Some(User { username: nix_user.name, display_name, home: nix_user.dir, uid: uid.as_raw() }) +} + +pub fn get_avatar_path(home: &Path, username: &str) -> Option { + get_avatar_path_with(home, username, Path::new(DEFAULT_ACCOUNTSSERVICE_DIR)) +} + +pub fn get_avatar_path_with(home: &Path, username: &str, accountsservice_dir: &Path) -> Option { + // ~/.face takes priority + let face = home.join(".face"); + if face.exists() && !face.is_symlink() { return Some(face); } + // AccountsService icon + if accountsservice_dir.exists() { + let icon = accountsservice_dir.join(username); + if icon.exists() && !icon.is_symlink() { return Some(icon); } + } + None +} + +pub fn get_default_avatar_path() -> String { + format!("{GRESOURCE_PREFIX}/default-avatar.svg") +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[test] fn current_user_exists() { + let u = get_current_user(); + assert!(u.is_some()); + let u = u.unwrap(); + assert!(!u.username.is_empty()); + } + + #[test] fn face_file_priority() { + let dir = tempfile::tempdir().unwrap(); + let face = dir.path().join(".face"); fs::write(&face, "img").unwrap(); + let icons = dir.path().join("icons"); fs::create_dir(&icons).unwrap(); + let icon = icons.join("test"); fs::write(&icon, "img").unwrap(); + assert_eq!(get_avatar_path_with(dir.path(), "test", &icons), Some(face)); + } + + #[test] fn accountsservice_fallback() { + let dir = tempfile::tempdir().unwrap(); + let icons = dir.path().join("icons"); fs::create_dir(&icons).unwrap(); + let icon = icons.join("test"); fs::write(&icon, "img").unwrap(); + assert_eq!(get_avatar_path_with(dir.path(), "test", &icons), Some(icon)); + } + + #[test] fn no_avatar() { + let dir = tempfile::tempdir().unwrap(); + assert!(get_avatar_path_with(dir.path(), "test", Path::new("/nonexistent")).is_none()); + } + + #[test] fn rejects_symlink() { + let dir = tempfile::tempdir().unwrap(); + let real = dir.path().join("real"); fs::write(&real, "x").unwrap(); + std::os::unix::fs::symlink(&real, dir.path().join(".face")).unwrap(); + assert!(get_avatar_path_with(dir.path(), "test", Path::new("/nonexistent")).is_none()); + } + + #[test] fn default_avatar_gresource() { + let p = get_default_avatar_path(); + assert!(p.contains("moonlock")); + assert!(p.contains("default-avatar.svg")); + } +} diff --git a/tests/__init__.py b/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/test_auth.py b/tests/test_auth.py deleted file mode 100644 index ef53b4a..0000000 --- a/tests/test_auth.py +++ /dev/null @@ -1,65 +0,0 @@ -# ABOUTME: Tests for PAM authentication via ctypes wrapper. -# ABOUTME: Verifies authenticate() calls libpam correctly and handles success/failure. - -from unittest.mock import patch, MagicMock, ANY -import ctypes - -from moonlock.auth import authenticate, PAM_SUCCESS, PAM_AUTH_ERR - - -class TestAuthenticate: - """Tests for PAM authentication.""" - - @patch("moonlock.auth._get_libpam") - def test_returns_true_on_successful_auth(self, mock_get_libpam): - libpam = MagicMock() - mock_get_libpam.return_value = libpam - libpam.pam_start.return_value = PAM_SUCCESS - libpam.pam_authenticate.return_value = PAM_SUCCESS - libpam.pam_acct_mgmt.return_value = PAM_SUCCESS - libpam.pam_end.return_value = PAM_SUCCESS - - assert authenticate("testuser", "correctpassword") is True - - @patch("moonlock.auth._get_libpam") - def test_returns_false_on_wrong_password(self, mock_get_libpam): - libpam = MagicMock() - mock_get_libpam.return_value = libpam - libpam.pam_start.return_value = PAM_SUCCESS - libpam.pam_authenticate.return_value = PAM_AUTH_ERR - libpam.pam_end.return_value = PAM_SUCCESS - - assert authenticate("testuser", "wrongpassword") is False - - @patch("moonlock.auth._get_libpam") - def test_pam_end_always_called(self, mock_get_libpam): - libpam = MagicMock() - mock_get_libpam.return_value = libpam - libpam.pam_start.return_value = PAM_SUCCESS - libpam.pam_authenticate.return_value = PAM_AUTH_ERR - libpam.pam_end.return_value = PAM_SUCCESS - - authenticate("testuser", "wrongpassword") - libpam.pam_end.assert_called_once() - - @patch("moonlock.auth._get_libpam") - def test_returns_false_when_pam_start_fails(self, mock_get_libpam): - libpam = MagicMock() - mock_get_libpam.return_value = libpam - libpam.pam_start.return_value = PAM_AUTH_ERR - - assert authenticate("testuser", "password") is False - - @patch("moonlock.auth._get_libpam") - def test_uses_moonlock_as_service_name(self, mock_get_libpam): - libpam = MagicMock() - mock_get_libpam.return_value = libpam - libpam.pam_start.return_value = PAM_SUCCESS - libpam.pam_authenticate.return_value = PAM_SUCCESS - libpam.pam_acct_mgmt.return_value = PAM_SUCCESS - libpam.pam_end.return_value = PAM_SUCCESS - - authenticate("testuser", "password") - args = libpam.pam_start.call_args - # First positional arg should be the service name - assert args[0][0] == b"moonlock" diff --git a/tests/test_config.py b/tests/test_config.py deleted file mode 100644 index 8a22c9f..0000000 --- a/tests/test_config.py +++ /dev/null @@ -1,42 +0,0 @@ -# ABOUTME: Tests for configuration loading. -# ABOUTME: Verifies TOML parsing, defaults, and path override behavior. - -from pathlib import Path - -from moonlock.config import Config, load_config - - -class TestLoadConfig: - """Tests for config loading.""" - - def test_defaults_when_no_config_file(self, tmp_path: Path): - nonexistent = tmp_path / "nonexistent.toml" - config = load_config(config_paths=[nonexistent]) - assert config.background_path is None - assert config.fingerprint_enabled is True - - def test_reads_background_path(self, tmp_path: Path): - config_file = tmp_path / "moonlock.toml" - config_file.write_text('background_path = "/usr/share/wallpapers/moon.jpg"\n') - config = load_config(config_paths=[config_file]) - assert config.background_path == "/usr/share/wallpapers/moon.jpg" - - def test_reads_fingerprint_disabled(self, tmp_path: Path): - config_file = tmp_path / "moonlock.toml" - config_file.write_text("fingerprint_enabled = false\n") - config = load_config(config_paths=[config_file]) - assert config.fingerprint_enabled is False - - def test_later_paths_override_earlier(self, tmp_path: Path): - system_conf = tmp_path / "system.toml" - system_conf.write_text('background_path = "/system/wallpaper.jpg"\n') - user_conf = tmp_path / "user.toml" - user_conf.write_text('background_path = "/home/user/wallpaper.jpg"\n') - config = load_config(config_paths=[system_conf, user_conf]) - assert config.background_path == "/home/user/wallpaper.jpg" - - def test_partial_config_uses_defaults(self, tmp_path: Path): - config_file = tmp_path / "moonlock.toml" - config_file.write_text('background_path = "/some/path.jpg"\n') - config = load_config(config_paths=[config_file]) - assert config.fingerprint_enabled is True diff --git a/tests/test_fingerprint.py b/tests/test_fingerprint.py deleted file mode 100644 index 72aae5c..0000000 --- a/tests/test_fingerprint.py +++ /dev/null @@ -1,237 +0,0 @@ -# ABOUTME: Tests for fprintd D-Bus integration. -# ABOUTME: Verifies fingerprint listener lifecycle and signal handling with mocked D-Bus. - -from unittest.mock import patch, MagicMock, call - -from moonlock.fingerprint import FingerprintListener - - -class TestFingerprintListenerAvailability: - """Tests for checking fprintd availability.""" - - @patch("moonlock.fingerprint.Gio.DBusProxy.new_for_bus_sync") - def test_is_available_when_fprintd_running_and_enrolled(self, mock_proxy_cls): - manager = MagicMock() - mock_proxy_cls.return_value = manager - manager.GetDefaultDevice.return_value = ("(o)", "/dev/0") - - device = MagicMock() - mock_proxy_cls.return_value = device - device.ListEnrolledFingers.return_value = ("(as)", ["right-index-finger"]) - - listener = FingerprintListener.__new__(FingerprintListener) - listener._manager_proxy = manager - listener._device_proxy = device - listener._device_path = "/dev/0" - - assert listener.is_available("testuser") is True - - def test_is_available_returns_false_when_no_device(self): - listener = FingerprintListener.__new__(FingerprintListener) - listener._device_proxy = None - listener._device_path = None - - assert listener.is_available("testuser") is False - - -class TestFingerprintListenerLifecycle: - """Tests for start/stop lifecycle.""" - - def test_start_calls_verify_start(self): - listener = FingerprintListener.__new__(FingerprintListener) - listener._device_proxy = MagicMock() - listener._device_path = "/dev/0" - listener._signal_id = None - listener._running = False - - on_success = MagicMock() - on_failure = MagicMock() - - listener.start("testuser", on_success=on_success, on_failure=on_failure) - - listener._device_proxy.Claim.assert_called_once_with("(s)", "testuser") - listener._device_proxy.VerifyStart.assert_called_once_with("(s)", "any") - - def test_stop_calls_verify_stop_and_release(self): - listener = FingerprintListener.__new__(FingerprintListener) - listener._device_proxy = MagicMock() - listener._running = True - listener._failed_attempts = 0 - listener._signal_id = 42 - - listener.stop() - - listener._device_proxy.VerifyStop.assert_called_once() - listener._device_proxy.Release.assert_called_once() - assert listener._running is False - - def test_stop_is_noop_when_not_running(self): - listener = FingerprintListener.__new__(FingerprintListener) - listener._device_proxy = MagicMock() - listener._running = False - - listener.stop() - - listener._device_proxy.VerifyStop.assert_not_called() - - -class TestFingerprintSignalHandling: - """Tests for VerifyStatus signal processing.""" - - def test_verify_match_calls_on_success(self): - listener = FingerprintListener.__new__(FingerprintListener) - listener._device_proxy = MagicMock() - listener._running = True - listener._failed_attempts = 0 - on_success = MagicMock() - on_failure = MagicMock() - listener._on_success = on_success - listener._on_failure = on_failure - - listener._on_verify_status("verify-match", False) - - on_success.assert_called_once() - - def test_verify_no_match_calls_on_failure_and_retries_when_done(self): - listener = FingerprintListener.__new__(FingerprintListener) - listener._device_proxy = MagicMock() - listener._running = True - listener._failed_attempts = 0 - on_success = MagicMock() - on_failure = MagicMock() - listener._on_success = on_success - listener._on_failure = on_failure - - listener._on_verify_status("verify-no-match", True) - - on_failure.assert_called_once() - # Should restart verification when done=True - listener._device_proxy.VerifyStart.assert_called_once_with("(s)", "any") - - def test_verify_no_match_calls_on_failure_without_restart_when_not_done(self): - listener = FingerprintListener.__new__(FingerprintListener) - listener._device_proxy = MagicMock() - listener._running = True - listener._failed_attempts = 0 - on_success = MagicMock() - on_failure = MagicMock() - listener._on_success = on_success - listener._on_failure = on_failure - - listener._on_verify_status("verify-no-match", False) - - on_failure.assert_called_once() - # Should NOT restart verification when done=False (still in progress) - listener._device_proxy.VerifyStart.assert_not_called() - - def test_verify_swipe_too_short_retries_when_done(self): - listener = FingerprintListener.__new__(FingerprintListener) - listener._device_proxy = MagicMock() - listener._running = True - listener._failed_attempts = 0 - on_success = MagicMock() - on_failure = MagicMock() - listener._on_success = on_success - listener._on_failure = on_failure - - listener._on_verify_status("verify-swipe-too-short", True) - - on_success.assert_not_called() - on_failure.assert_not_called() - listener._device_proxy.VerifyStart.assert_called_once_with("(s)", "any") - - def test_retry_status_does_not_restart_when_not_done(self): - listener = FingerprintListener.__new__(FingerprintListener) - listener._device_proxy = MagicMock() - listener._running = True - listener._failed_attempts = 0 - on_success = MagicMock() - on_failure = MagicMock() - listener._on_success = on_success - listener._on_failure = on_failure - - listener._on_verify_status("verify-swipe-too-short", False) - - on_success.assert_not_called() - on_failure.assert_not_called() - # Should NOT restart — verification still in progress - listener._device_proxy.VerifyStart.assert_not_called() - - -class TestFingerprintStartErrorHandling: - """Tests for GLib.Error handling in start().""" - - def test_claim_glib_error_logs_and_returns_without_starting(self): - """When Claim() raises GLib.Error, start() should not proceed.""" - from gi.repository import GLib - - listener = FingerprintListener.__new__(FingerprintListener) - listener._device_proxy = MagicMock() - listener._device_path = "/dev/0" - listener._signal_id = None - listener._running = False - listener._on_success = None - listener._on_failure = None - - listener._device_proxy.Claim.side_effect = GLib.Error( - "net.reactivated.Fprint.Error.AlreadyClaimed" - ) - - on_success = MagicMock() - on_failure = MagicMock() - - listener.start("testuser", on_success=on_success, on_failure=on_failure) - - # Should NOT have connected signals or started verification - listener._device_proxy.connect.assert_not_called() - listener._device_proxy.VerifyStart.assert_not_called() - assert listener._running is False - - def test_verify_start_glib_error_disconnects_and_releases(self): - """When VerifyStart() raises GLib.Error, start() should clean up.""" - from gi.repository import GLib - - listener = FingerprintListener.__new__(FingerprintListener) - listener._device_proxy = MagicMock() - listener._device_path = "/dev/0" - listener._signal_id = None - listener._running = False - listener._on_success = None - listener._on_failure = None - - # Claim succeeds, signal connect returns an ID, VerifyStart fails - listener._device_proxy.connect.return_value = 99 - listener._device_proxy.VerifyStart.side_effect = GLib.Error( - "net.reactivated.Fprint.Error.Internal" - ) - - on_success = MagicMock() - on_failure = MagicMock() - - listener.start("testuser", on_success=on_success, on_failure=on_failure) - - # Should have disconnected the signal - listener._device_proxy.disconnect.assert_called_once_with(99) - # Should have released the device - listener._device_proxy.Release.assert_called_once() - assert listener._running is False - assert listener._signal_id is None - - def test_start_sets_running_true_only_on_success(self): - """_running should only be True after both Claim and VerifyStart succeed.""" - listener = FingerprintListener.__new__(FingerprintListener) - listener._device_proxy = MagicMock() - listener._device_path = "/dev/0" - listener._signal_id = None - listener._running = False - listener._on_success = None - listener._on_failure = None - - listener._device_proxy.connect.return_value = 42 - - on_success = MagicMock() - on_failure = MagicMock() - - listener.start("testuser", on_success=on_success, on_failure=on_failure) - - assert listener._running is True diff --git a/tests/test_i18n.py b/tests/test_i18n.py deleted file mode 100644 index 0f0db97..0000000 --- a/tests/test_i18n.py +++ /dev/null @@ -1,67 +0,0 @@ -# ABOUTME: Tests for locale detection and string lookup. -# ABOUTME: Verifies correct language detection from env vars and /etc/locale.conf. - -import os -from pathlib import Path -from unittest.mock import patch - -from moonlock.i18n import Strings, detect_locale, load_strings - - -class TestDetectLocale: - """Tests for locale detection.""" - - @patch.dict(os.environ, {"LANG": "de_DE.UTF-8"}) - def test_detects_german_from_env(self): - assert detect_locale() == "de" - - @patch.dict(os.environ, {"LANG": "en_US.UTF-8"}) - def test_detects_english_from_env(self): - assert detect_locale() == "en" - - @patch.dict(os.environ, {"LANG": ""}, clear=False) - def test_reads_locale_conf_when_env_empty(self, tmp_path: Path): - locale_conf = tmp_path / "locale.conf" - locale_conf.write_text("LANG=de_DE.UTF-8\n") - assert detect_locale(locale_conf_path=locale_conf) == "de" - - @patch.dict(os.environ, {"LANG": "C"}) - def test_c_locale_defaults_to_english(self): - assert detect_locale() == "en" - - @patch.dict(os.environ, {"LANG": "POSIX"}) - def test_posix_locale_defaults_to_english(self): - assert detect_locale() == "en" - - @patch.dict(os.environ, {}, clear=True) - def test_missing_env_and_no_file_defaults_to_english(self, tmp_path: Path): - nonexistent = tmp_path / "nonexistent" - assert detect_locale(locale_conf_path=nonexistent) == "en" - - -class TestLoadStrings: - """Tests for string table loading.""" - - def test_german_strings(self): - strings = load_strings("de") - assert isinstance(strings, Strings) - assert strings.password_placeholder == "Passwort" - - def test_english_strings(self): - strings = load_strings("en") - assert strings.password_placeholder == "Password" - - def test_unknown_locale_falls_back_to_english(self): - strings = load_strings("fr") - assert strings.password_placeholder == "Password" - - def test_lockscreen_specific_strings_exist(self): - strings = load_strings("de") - assert strings.unlock_button is not None - assert strings.fingerprint_prompt is not None - assert strings.fingerprint_success is not None - - def test_faillock_template_strings(self): - strings = load_strings("de") - msg = strings.faillock_attempts_remaining.format(n=2) - assert "2" in msg diff --git a/tests/test_integration.py b/tests/test_integration.py deleted file mode 100644 index 9b3220e..0000000 --- a/tests/test_integration.py +++ /dev/null @@ -1,168 +0,0 @@ -# ABOUTME: Integration tests for the complete auth flow. -# ABOUTME: Tests password and fingerprint unlock paths end-to-end (mocked PAM/fprintd). - -from unittest.mock import patch, MagicMock, PropertyMock - -from moonlock.lockscreen import LockscreenWindow, FAILLOCK_MAX_ATTEMPTS - - -class TestPasswordAuthFlow: - """Integration tests for password authentication flow.""" - - @patch("moonlock.lockscreen.FingerprintListener") - @patch("moonlock.lockscreen.get_avatar_path", return_value=None) - @patch("moonlock.lockscreen.get_current_user") - @patch("moonlock.lockscreen.authenticate") - def test_successful_password_unlock(self, mock_auth, mock_user, mock_avatar, mock_fp): - """Successful password auth should trigger unlock callback.""" - mock_user.return_value = MagicMock( - username="testuser", display_name="Test", home="/tmp", uid=1000 - ) - mock_fp_instance = MagicMock() - mock_fp_instance.is_available.return_value = False - mock_fp.return_value = mock_fp_instance - mock_auth.return_value = True - - unlock_called = [] - # We can't create a real GTK window without a display, so test the auth logic directly - from moonlock.auth import authenticate - result = authenticate.__wrapped__("testuser", "correct") if hasattr(authenticate, '__wrapped__') else mock_auth("testuser", "correct") - assert result is True - - @patch("moonlock.lockscreen.authenticate", return_value=False) - def test_failed_password_increments_counter(self, mock_auth): - """Failed password should increment failed attempts.""" - # Test the counter logic directly - failed_attempts = 0 - result = mock_auth("testuser", "wrong") - if not result: - failed_attempts += 1 - assert failed_attempts == 1 - assert result is False - - def test_faillock_warning_after_threshold(self): - """Faillock warning should appear near max attempts.""" - from moonlock.i18n import load_strings - strings = load_strings("de") - failed = FAILLOCK_MAX_ATTEMPTS - 1 - remaining = FAILLOCK_MAX_ATTEMPTS - failed - msg = strings.faillock_attempts_remaining.format(n=remaining) - assert "1" in msg - - def test_faillock_locked_at_max_attempts(self): - """Account locked message at max failed attempts.""" - from moonlock.i18n import load_strings - strings = load_strings("de") - assert strings.faillock_locked - - -class TestFingerprintAuthFlow: - """Integration tests for fingerprint authentication flow.""" - - def test_fingerprint_success_triggers_unlock(self): - """verify-match signal should lead to unlock.""" - from moonlock.fingerprint import FingerprintListener - - listener = FingerprintListener.__new__(FingerprintListener) - listener._device_proxy = MagicMock() - listener._running = True - listener._failed_attempts = 0 - - unlock_called = [] - listener._on_success = lambda: unlock_called.append(True) - listener._on_failure = MagicMock() - - listener._on_verify_status("verify-match", False) - assert len(unlock_called) == 1 - - def test_fingerprint_no_match_retries_when_done(self): - """verify-no-match with done=True should call on_failure and restart verification.""" - from moonlock.fingerprint import FingerprintListener - - listener = FingerprintListener.__new__(FingerprintListener) - listener._device_proxy = MagicMock() - listener._running = True - listener._failed_attempts = 0 - listener._on_success = MagicMock() - listener._on_failure = MagicMock() - - listener._on_verify_status("verify-no-match", True) - - listener._on_failure.assert_called_once() - listener._device_proxy.VerifyStart.assert_called_once() - - def test_fingerprint_no_match_no_restart_when_not_done(self): - """verify-no-match with done=False should call on_failure but not restart.""" - from moonlock.fingerprint import FingerprintListener - - listener = FingerprintListener.__new__(FingerprintListener) - listener._device_proxy = MagicMock() - listener._running = True - listener._failed_attempts = 0 - listener._on_success = MagicMock() - listener._on_failure = MagicMock() - - listener._on_verify_status("verify-no-match", False) - - listener._on_failure.assert_called_once() - listener._device_proxy.VerifyStart.assert_not_called() - - def test_fingerprint_and_password_independent(self): - """Both auth methods should work independently.""" - from moonlock.fingerprint import FingerprintListener - from moonlock.auth import authenticate - - # Fingerprint path - listener = FingerprintListener.__new__(FingerprintListener) - listener._device_proxy = MagicMock() - listener._running = True - listener._failed_attempts = 0 - fp_unlock = [] - listener._on_success = lambda: fp_unlock.append(True) - listener._on_failure = MagicMock() - listener._on_verify_status("verify-match", False) - assert len(fp_unlock) == 1 - - # Password path (mocked) - with patch("moonlock.auth._get_libpam") as mock_pam: - from moonlock.auth import PAM_SUCCESS - libpam = MagicMock() - mock_pam.return_value = libpam - libpam.pam_start.return_value = PAM_SUCCESS - libpam.pam_authenticate.return_value = PAM_SUCCESS - libpam.pam_acct_mgmt.return_value = PAM_SUCCESS - libpam.pam_end.return_value = PAM_SUCCESS - assert authenticate("testuser", "correct") is True - - -class TestSecurityConstraints: - """Tests for security-related behavior.""" - - def test_escape_does_not_unlock(self): - """Escape key should only clear the field, not unlock.""" - # Escape should clear password, not trigger any auth - from gi.repository import Gdk - assert Gdk.KEY_Escape != Gdk.KEY_Return - - def test_empty_password_not_submitted(self): - """Empty password should not trigger PAM auth.""" - with patch("moonlock.auth._get_libpam") as mock_pam: - # The lockscreen checks for empty password before calling authenticate - password = "" - assert not password # falsy, so auth should not be called - mock_pam.assert_not_called() - - def test_pam_service_name_is_moonlock(self): - """PAM should use 'moonlock' as service name, not 'login' or 'sudo'.""" - with patch("moonlock.auth._get_libpam") as mock_pam: - from moonlock.auth import PAM_SUCCESS - libpam = MagicMock() - mock_pam.return_value = libpam - libpam.pam_start.return_value = PAM_SUCCESS - libpam.pam_authenticate.return_value = PAM_SUCCESS - libpam.pam_acct_mgmt.return_value = PAM_SUCCESS - libpam.pam_end.return_value = PAM_SUCCESS - - from moonlock.auth import authenticate - authenticate("user", "pass") - assert libpam.pam_start.call_args[0][0] == b"moonlock" diff --git a/tests/test_main.py b/tests/test_main.py deleted file mode 100644 index 29d7b12..0000000 --- a/tests/test_main.py +++ /dev/null @@ -1,229 +0,0 @@ -# ABOUTME: Tests for the Moonlock application entry point. -# ABOUTME: Covers logging setup, defensive window creation, and CSS error handling. - -import logging -import sys -from pathlib import Path -from unittest.mock import patch, MagicMock - -import pytest - - -@pytest.fixture(autouse=True) -def _mock_gtk(monkeypatch): - """Prevent GTK from requiring a display during test collection.""" - mock_gi = MagicMock() - mock_gtk = MagicMock() - mock_gdk = MagicMock() - mock_session_lock = MagicMock() - - # Pre-populate gi.repository with our mocks - modules = { - "gi": mock_gi, - "gi.repository": MagicMock(Gtk=mock_gtk, Gdk=mock_gdk, Gtk4SessionLock=mock_session_lock), - } - - with monkeypatch.context() as m: - # Only patch missing/problematic modules if not already loaded - for mod_name, mod in modules.items(): - if mod_name not in sys.modules: - m.setitem(sys.modules, mod_name, mod) - yield - - -def _import_main(): - """Import main module lazily after GTK mocking is in place.""" - from moonlock.main import MoonlockApp, _setup_logging - return MoonlockApp, _setup_logging - - -class TestSetupLogging: - """Tests for the logging infrastructure.""" - - def test_setup_logging_adds_stderr_handler(self): - """_setup_logging() should add a StreamHandler to the root logger.""" - _, _setup_logging = _import_main() - root = logging.getLogger() - handlers_before = len(root.handlers) - - _setup_logging() - - assert len(root.handlers) > handlers_before - - # Clean up handlers we added - for handler in root.handlers[handlers_before:]: - root.removeHandler(handler) - - def test_setup_logging_sets_info_level(self): - """_setup_logging() should set root logger to INFO level.""" - _, _setup_logging = _import_main() - root = logging.getLogger() - original_level = root.level - - _setup_logging() - - assert root.level == logging.INFO - - # Restore - root.setLevel(original_level) - for handler in root.handlers[:]: - root.removeHandler(handler) - - @patch("moonlock.main._LOG_DIR") - @patch("logging.FileHandler") - def test_setup_logging_adds_file_handler_when_dir_exists(self, mock_fh, mock_log_dir): - """_setup_logging() should add a FileHandler when log directory exists.""" - _, _setup_logging = _import_main() - mock_log_dir.is_dir.return_value = True - - root = logging.getLogger() - handlers_before = len(root.handlers) - - _setup_logging() - - mock_fh.assert_called_once() - - # Clean up - for handler in root.handlers[handlers_before:]: - root.removeHandler(handler) - - @patch("moonlock.main._LOG_DIR") - def test_setup_logging_skips_file_handler_when_dir_missing(self, mock_log_dir): - """_setup_logging() should not fail when log directory doesn't exist.""" - _, _setup_logging = _import_main() - mock_log_dir.is_dir.return_value = False - - root = logging.getLogger() - handlers_before = len(root.handlers) - - _setup_logging() - - assert len(root.handlers) >= handlers_before - - # Clean up - for handler in root.handlers[handlers_before:]: - root.removeHandler(handler) - - -class TestCssErrorHandling: - """Tests for CSS loading error handling.""" - - @patch("moonlock.main.Gdk.Display.get_default") - @patch("moonlock.main.Gtk.CssProvider") - @patch("moonlock.main.files") - def test_load_css_logs_error_on_exception(self, mock_files, mock_css_cls, mock_display): - """CSS loading errors should be logged, not raised.""" - MoonlockApp, _ = _import_main() - mock_files.return_value.__truediv__ = MagicMock(return_value=Path("/nonexistent")) - mock_css = MagicMock() - mock_css.load_from_path.side_effect = Exception("CSS parse error") - mock_css_cls.return_value = mock_css - - app = MoonlockApp.__new__(MoonlockApp) - app._config = MagicMock() - - # Should not raise - with patch("moonlock.main.logger") as mock_logger: - app._load_css() - mock_logger.exception.assert_called_once() - - -class TestDefensiveWindowCreation: - """Tests for defensive window creation in session lock mode.""" - - @patch("moonlock.main.LockscreenWindow") - @patch("moonlock.main.FingerprintListener") - @patch("moonlock.main.Gdk.Display.get_default") - @patch("moonlock.main.Gtk4SessionLock") - def test_single_window_failure_does_not_stop_other_windows( - self, mock_session_lock, mock_display, mock_fp, mock_window_cls - ): - """If one window fails, others should still be created.""" - MoonlockApp, _ = _import_main() - app = MoonlockApp.__new__(MoonlockApp) - app._config = MagicMock() - app._windows = [] - - # Two monitors - monitor1 = MagicMock() - monitor2 = MagicMock() - monitors = MagicMock() - monitors.get_n_items.return_value = 2 - monitors.get_item.side_effect = [monitor1, monitor2] - mock_display.return_value.get_monitors.return_value = monitors - - lock_instance = MagicMock() - mock_session_lock.Instance.new.return_value = lock_instance - app._lock_instance = lock_instance - - # First window creation fails, second succeeds - window_ok = MagicMock() - mock_window_cls.side_effect = [Exception("GTK error"), window_ok] - - with patch("moonlock.main.logger"): - app._activate_with_session_lock() - - # One window should have been created despite the first failure - assert len(app._windows) == 1 - - @patch("moonlock.main.LockscreenWindow") - @patch("moonlock.main.FingerprintListener") - @patch("moonlock.main.Gdk.Display.get_default") - @patch("moonlock.main.Gtk4SessionLock") - def test_all_windows_fail_does_not_unlock_session( - self, mock_session_lock, mock_display, mock_fp, mock_window_cls - ): - """If ALL windows fail, session stays locked (compositor policy).""" - MoonlockApp, _ = _import_main() - app = MoonlockApp.__new__(MoonlockApp) - app._config = MagicMock() - app._windows = [] - - # One monitor - monitors = MagicMock() - monitors.get_n_items.return_value = 1 - monitors.get_item.return_value = MagicMock() - mock_display.return_value.get_monitors.return_value = monitors - - lock_instance = MagicMock() - mock_session_lock.Instance.new.return_value = lock_instance - app._lock_instance = lock_instance - - # Window creation fails - mock_window_cls.side_effect = Exception("GTK error") - - with patch("moonlock.main.logger"): - app._activate_with_session_lock() - - # Session must NOT be unlocked — compositor keeps screen locked - lock_instance.unlock.assert_not_called() - - -class TestExcepthook: - """Tests for the global exception handler.""" - - def test_excepthook_does_not_unlock(self): - """Unhandled exceptions must NOT unlock the session.""" - MoonlockApp, _ = _import_main() - from moonlock.main import _install_excepthook - - app = MoonlockApp.__new__(MoonlockApp) - app._lock_instance = MagicMock() - - original_hook = sys.excepthook - _install_excepthook() - - try: - with patch("moonlock.main.logger"): - sys.excepthook(RuntimeError, RuntimeError("crash"), None) - - # Must NOT have called unlock - app._lock_instance.unlock.assert_not_called() - finally: - sys.excepthook = original_hook - - def test_no_sigusr1_handler(self): - """SIGUSR1 must NOT be handled — signal-based unlock is a security hole.""" - import signal - handler = signal.getsignal(signal.SIGUSR1) - assert handler is signal.SIG_DFL diff --git a/tests/test_power.py b/tests/test_power.py deleted file mode 100644 index a1cac9c..0000000 --- a/tests/test_power.py +++ /dev/null @@ -1,24 +0,0 @@ -# ABOUTME: Tests for power actions (reboot, shutdown). -# ABOUTME: Verifies loginctl commands are called correctly. - -from unittest.mock import patch, call - -from moonlock.power import reboot, shutdown - - -class TestReboot: - """Tests for the reboot function.""" - - @patch("moonlock.power.subprocess.run") - def test_reboot_calls_loginctl(self, mock_run): - reboot() - mock_run.assert_called_once_with(["loginctl", "reboot"], check=True) - - -class TestShutdown: - """Tests for the shutdown function.""" - - @patch("moonlock.power.subprocess.run") - def test_shutdown_calls_loginctl(self, mock_run): - shutdown() - mock_run.assert_called_once_with(["loginctl", "poweroff"], check=True) diff --git a/tests/test_security.py b/tests/test_security.py deleted file mode 100644 index db3ae8d..0000000 --- a/tests/test_security.py +++ /dev/null @@ -1,24 +0,0 @@ -# ABOUTME: Tests for security-related functionality. -# ABOUTME: Verifies password wiping, PAM cleanup, and lockscreen bypass prevention. - -from moonlock.auth import _wipe_bytes - - -class TestPasswordWiping: - """Tests for sensitive data cleanup.""" - - def test_wipe_bytes_zeroes_bytearray(self): - data = bytearray(b"secretpassword") - _wipe_bytes(data) - assert data == bytearray(len(b"secretpassword")) - assert all(b == 0 for b in data) - - def test_wipe_bytes_handles_empty(self): - data = bytearray(b"") - _wipe_bytes(data) - assert data == bytearray(b"") - - def test_wipe_bytes_handles_bytes_gracefully(self): - # Regular bytes are immutable, wipe should be a no-op - data = b"secret" - _wipe_bytes(data) # should not raise diff --git a/tests/test_users.py b/tests/test_users.py deleted file mode 100644 index 1808718..0000000 --- a/tests/test_users.py +++ /dev/null @@ -1,96 +0,0 @@ -# ABOUTME: Tests for current user detection and avatar loading. -# ABOUTME: Verifies user info retrieval from the system. - -import os -from pathlib import Path -from unittest.mock import patch - -from moonlock.users import get_current_user, get_avatar_path, get_default_avatar_path, User - - -class TestGetCurrentUser: - """Tests for current user detection.""" - - @patch("moonlock.users.os.getuid", return_value=1000) - @patch("moonlock.users.pwd.getpwuid") - def test_returns_user_with_correct_username(self, mock_pwd, mock_uid): - mock_pwd.return_value.pw_name = "testuser" - mock_pwd.return_value.pw_gecos = "Test User" - mock_pwd.return_value.pw_dir = "/home/testuser" - mock_pwd.return_value.pw_uid = 1000 - user = get_current_user() - assert user.username == "testuser" - assert user.display_name == "Test User" - assert user.home == Path("/home/testuser") - mock_pwd.assert_called_once_with(1000) - - @patch("moonlock.users.os.getuid", return_value=1000) - @patch("moonlock.users.pwd.getpwuid") - def test_empty_gecos_falls_back_to_username(self, mock_pwd, mock_uid): - mock_pwd.return_value.pw_name = "testuser" - mock_pwd.return_value.pw_gecos = "" - mock_pwd.return_value.pw_dir = "/home/testuser" - mock_pwd.return_value.pw_uid = 1000 - user = get_current_user() - assert user.display_name == "testuser" - - @patch("moonlock.users.os.getuid", return_value=1000) - @patch("moonlock.users.pwd.getpwuid") - def test_gecos_with_commas_uses_first_field(self, mock_pwd, mock_uid): - mock_pwd.return_value.pw_name = "testuser" - mock_pwd.return_value.pw_gecos = "Test User,,,Room 42" - mock_pwd.return_value.pw_dir = "/home/testuser" - mock_pwd.return_value.pw_uid = 1000 - user = get_current_user() - assert user.display_name == "Test User" - - -class TestGetAvatarPath: - """Tests for avatar path resolution.""" - - def test_returns_face_file_if_exists(self, tmp_path: Path): - face = tmp_path / ".face" - face.write_text("fake image") - path = get_avatar_path(tmp_path) - assert path == face - - def test_returns_accountsservice_icon_if_exists(self, tmp_path: Path): - username = "testuser" - icons_dir = tmp_path / "icons" - icons_dir.mkdir() - icon = icons_dir / username - icon.write_text("fake image") - path = get_avatar_path( - tmp_path, username=username, accountsservice_dir=icons_dir - ) - assert path == icon - - def test_face_file_takes_priority_over_accountsservice(self, tmp_path: Path): - face = tmp_path / ".face" - face.write_text("fake image") - icons_dir = tmp_path / "icons" - icons_dir.mkdir() - icon = icons_dir / "testuser" - icon.write_text("fake image") - path = get_avatar_path( - tmp_path, username="testuser", accountsservice_dir=icons_dir - ) - assert path == face - - def test_returns_none_when_no_avatar(self, tmp_path: Path): - path = get_avatar_path(tmp_path) - assert path is None - - -class TestGetDefaultAvatarPath: - """Tests for default avatar fallback.""" - - def test_default_avatar_exists(self): - """The package default avatar must always be present.""" - path = get_default_avatar_path() - assert path.is_file() - - def test_default_avatar_is_svg(self): - """The default avatar should be an SVG file.""" - path = get_default_avatar_path() - assert path.suffix == ".svg" diff --git a/tests/test_wallpaper.py b/tests/test_wallpaper.py deleted file mode 100644 index 37d46f5..0000000 --- a/tests/test_wallpaper.py +++ /dev/null @@ -1,53 +0,0 @@ -# ABOUTME: Tests for wallpaper path resolution. -# ABOUTME: Verifies fallback hierarchy: config > Moonarch system default > package fallback. - -from pathlib import Path -from unittest.mock import patch - -from moonlock.config import Config, resolve_background_path, MOONARCH_WALLPAPER, PACKAGE_WALLPAPER - - -class TestResolveBackgroundPath: - """Tests for the wallpaper fallback hierarchy.""" - - def test_config_path_used_when_file_exists(self, tmp_path: Path): - """Config background_path takes priority if the file exists.""" - wallpaper = tmp_path / "custom.jpg" - wallpaper.write_bytes(b"\xff\xd8") - config = Config(background_path=str(wallpaper)) - result = resolve_background_path(config) - assert result == wallpaper - - def test_config_path_skipped_when_file_missing(self, tmp_path: Path): - """Config path should be skipped if the file does not exist.""" - config = Config(background_path="/nonexistent/wallpaper.jpg") - with patch("moonlock.config.MOONARCH_WALLPAPER", tmp_path / "nope.jpg"): - result = resolve_background_path(config) - assert result == PACKAGE_WALLPAPER - - def test_moonarch_default_used_when_no_config(self, tmp_path: Path): - """Moonarch system wallpaper is used when config has no background_path.""" - moonarch_wp = tmp_path / "wallpaper.jpg" - moonarch_wp.write_bytes(b"\xff\xd8") - config = Config(background_path=None) - with patch("moonlock.config.MOONARCH_WALLPAPER", moonarch_wp): - result = resolve_background_path(config) - assert result == moonarch_wp - - def test_moonarch_default_skipped_when_missing(self, tmp_path: Path): - """Falls back to package wallpaper when Moonarch default is missing.""" - config = Config(background_path=None) - with patch("moonlock.config.MOONARCH_WALLPAPER", tmp_path / "nope.jpg"): - result = resolve_background_path(config) - assert result == PACKAGE_WALLPAPER - - def test_package_fallback_always_exists(self): - """The package fallback wallpaper must always be present.""" - assert PACKAGE_WALLPAPER.is_file() - - def test_full_fallback_chain(self, tmp_path: Path): - """With no config and no Moonarch default, package fallback is returned.""" - config = Config() - with patch("moonlock.config.MOONARCH_WALLPAPER", tmp_path / "nope.jpg"): - result = resolve_background_path(config) - assert result == PACKAGE_WALLPAPER diff --git a/uv.lock b/uv.lock deleted file mode 100644 index 1b730b1..0000000 --- a/uv.lock +++ /dev/null @@ -1,45 +0,0 @@ -version = 1 -revision = 3 -requires-python = ">=3.11" - -[[package]] -name = "moonlock" -version = "0.2.0" -source = { editable = "." } -dependencies = [ - { name = "pygobject" }, -] - -[package.metadata] -requires-dist = [{ name = "pygobject", specifier = ">=3.46" }] - -[[package]] -name = "pycairo" -version = "1.29.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/22/d9/1728840a22a4ef8a8f479b9156aa2943cd98c3907accd3849fb0d5f82bfd/pycairo-1.29.0.tar.gz", hash = "sha256:f3f7fde97325cae80224c09f12564ef58d0d0f655da0e3b040f5807bd5bd3142", size = 665871, upload-time = "2025-11-11T19:13:01.584Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/31/92/1b904087e831806a449502786d47d3a468e5edb8f65755f6bd88e8038e53/pycairo-1.29.0-cp311-cp311-win32.whl", hash = "sha256:12757ebfb304b645861283c20585c9204c3430671fad925419cba04844d6dfed", size = 751342, upload-time = "2025-11-11T19:11:37.386Z" }, - { url = "https://files.pythonhosted.org/packages/db/09/a0ab6a246a7ede89e817d749a941df34f27a74bedf15551da51e86ae105e/pycairo-1.29.0-cp311-cp311-win_amd64.whl", hash = "sha256:3391532db03f9601c1cee9ebfa15b7d1db183c6020f3e75c1348cee16825934f", size = 845036, upload-time = "2025-11-11T19:11:43.408Z" }, - { url = "https://files.pythonhosted.org/packages/3c/b2/bf455454bac50baef553e7356d36b9d16e482403bf132cfb12960d2dc2e7/pycairo-1.29.0-cp311-cp311-win_arm64.whl", hash = "sha256:b69be8bb65c46b680771dc6a1a422b1cdd0cffb17be548f223e8cbbb6205567c", size = 694644, upload-time = "2025-11-11T19:11:48.599Z" }, - { url = "https://files.pythonhosted.org/packages/f6/28/6363087b9e60af031398a6ee5c248639eefc6cc742884fa2789411b1f73b/pycairo-1.29.0-cp312-cp312-win32.whl", hash = "sha256:91bcd7b5835764c616a615d9948a9afea29237b34d2ed013526807c3d79bb1d0", size = 751486, upload-time = "2025-11-11T19:11:54.451Z" }, - { url = "https://files.pythonhosted.org/packages/3a/d2/d146f1dd4ef81007686ac52231dd8f15ad54cf0aa432adaefc825475f286/pycairo-1.29.0-cp312-cp312-win_amd64.whl", hash = "sha256:3f01c3b5e49ef9411fff6bc7db1e765f542dc1c9cfed4542958a5afa3a8b8e76", size = 845383, upload-time = "2025-11-11T19:12:01.551Z" }, - { url = "https://files.pythonhosted.org/packages/01/16/6e6f33bb79ec4a527c9e633915c16dc55a60be26b31118dbd0d5859e8c51/pycairo-1.29.0-cp312-cp312-win_arm64.whl", hash = "sha256:eafe3d2076f3533535ad4a361fa0754e0ee66b90e548a3a0f558fed00b1248f2", size = 694518, upload-time = "2025-11-11T19:12:06.561Z" }, - { url = "https://files.pythonhosted.org/packages/f0/21/3f477dc318dd4e84a5ae6301e67284199d7e5a2384f3063714041086b65d/pycairo-1.29.0-cp313-cp313-win32.whl", hash = "sha256:3eb382a4141591807073274522f7aecab9e8fa2f14feafd11ac03a13a58141d7", size = 750949, upload-time = "2025-11-11T19:12:12.198Z" }, - { url = "https://files.pythonhosted.org/packages/43/34/7d27a333c558d6ac16dbc12a35061d389735e99e494ee4effa4ec6d99bed/pycairo-1.29.0-cp313-cp313-win_amd64.whl", hash = "sha256:91114e4b3fbf4287c2b0788f83e1f566ce031bda49cf1c3c3c19c3e986e95c38", size = 844149, upload-time = "2025-11-11T19:12:19.171Z" }, - { url = "https://files.pythonhosted.org/packages/15/43/e782131e23df69e5c8e631a016ed84f94bbc4981bf6411079f57af730a23/pycairo-1.29.0-cp313-cp313-win_arm64.whl", hash = "sha256:09b7f69a5ff6881e151354ea092137b97b0b1f0b2ab4eb81c92a02cc4a08e335", size = 693595, upload-time = "2025-11-11T19:12:23.445Z" }, - { url = "https://files.pythonhosted.org/packages/2d/fa/87eaeeb9d53344c769839d7b2854db7ff2cd596211e00dd1b702eeb1838f/pycairo-1.29.0-cp314-cp314-win32.whl", hash = "sha256:69e2a7968a3fbb839736257bae153f547bca787113cc8d21e9e08ca4526e0b6b", size = 767198, upload-time = "2025-11-11T19:12:42.336Z" }, - { url = "https://files.pythonhosted.org/packages/3c/90/3564d0f64d0a00926ab863dc3c4a129b1065133128e96900772e1c4421f8/pycairo-1.29.0-cp314-cp314-win_amd64.whl", hash = "sha256:e91243437a21cc4c67c401eff4433eadc45745275fa3ade1a0d877e50ffb90da", size = 871579, upload-time = "2025-11-11T19:12:48.982Z" }, - { url = "https://files.pythonhosted.org/packages/5e/91/93632b6ba12ad69c61991e3208bde88486fdfc152be8cfdd13444e9bc650/pycairo-1.29.0-cp314-cp314-win_arm64.whl", hash = "sha256:b72200ea0e5f73ae4c788cd2028a750062221385eb0e6d8f1ecc714d0b4fdf82", size = 719537, upload-time = "2025-11-11T19:12:55.016Z" }, - { url = "https://files.pythonhosted.org/packages/93/23/37053c039f8d3b9b5017af9bc64d27b680c48a898d48b72e6d6583cf0155/pycairo-1.29.0-cp314-cp314t-win_amd64.whl", hash = "sha256:5e45fce6185f553e79e4ef1722b8e98e6cde9900dbc48cb2637a9ccba86f627a", size = 874015, upload-time = "2025-11-11T19:12:28.47Z" }, - { url = "https://files.pythonhosted.org/packages/d7/54/123f6239685f5f3f2edc123f1e38d2eefacebee18cf3c532d2f4bd51d0ef/pycairo-1.29.0-cp314-cp314t-win_arm64.whl", hash = "sha256:caba0837a4b40d47c8dfb0f24cccc12c7831e3dd450837f2a356c75f21ce5a15", size = 721404, upload-time = "2025-11-11T19:12:36.919Z" }, -] - -[[package]] -name = "pygobject" -version = "3.56.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pycairo" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a2/80/09247a2be28af2c2240132a0af6c1005a2b1d089242b13a2cd782d2de8d7/pygobject-3.56.2.tar.gz", hash = "sha256:b816098969544081de9eecedb94ad6ac59c77e4d571fe7051f18bebcec074313", size = 1409059, upload-time = "2026-03-25T16:14:04.008Z" } From 60e63a6857b5343f55d76c5d1e90feea8a57c24c Mon Sep 17 00:00:00 2001 From: nevaforget Date: Fri, 27 Mar 2026 23:13:23 +0100 Subject: [PATCH 2/2] Fix Rust 2024 unsafe block warnings in PAM callback Wrap raw pointer operations in explicit unsafe blocks inside the unsafe extern "C" conv callback, as required by Rust 2024 edition. Remove unused mut binding. --- src/auth.rs | 56 ++++++++++++++++++++++++----------------------- src/lockscreen.rs | 2 +- 2 files changed, 30 insertions(+), 28 deletions(-) diff --git a/src/auth.rs b/src/auth.rs index 6a16910..df9e2af 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -64,34 +64,36 @@ unsafe extern "C" fn pam_conv_callback( resp: *mut *mut PamResponse, appdata_ptr: *mut libc::c_void, ) -> libc::c_int { - // Safety: appdata_ptr was set to a valid *const CString in authenticate() - let password = appdata_ptr as *const CString; - if password.is_null() { - return 7; // PAM_AUTH_ERR + unsafe { + // Safety: appdata_ptr was set to a valid *const CString in authenticate() + let password = appdata_ptr as *const CString; + if password.is_null() { + return 7; // PAM_AUTH_ERR + } + + // Safety: calloc returns zeroed memory for num_msg PamResponse structs. + // PAM owns this memory and will free() it. + let resp_array = libc::calloc( + num_msg as libc::size_t, + std::mem::size_of::() as libc::size_t, + ) as *mut PamResponse; + + if resp_array.is_null() { + return 7; // PAM_AUTH_ERR + } + + for i in 0..num_msg as isize { + // Safety: strdup allocates with malloc — PAM will free() the resp strings. + // We dereference password which is valid for the lifetime of authenticate(). + let resp_ptr = resp_array.offset(i); + (*resp_ptr).resp = libc::strdup((*password).as_ptr()); + (*resp_ptr).resp_retcode = 0; + } + + // Safety: resp is a valid pointer provided by PAM + *resp = resp_array; + PAM_SUCCESS } - - // Safety: calloc returns zeroed memory for num_msg PamResponse structs. - // PAM owns this memory and will free() it. - let resp_array = libc::calloc( - num_msg as libc::size_t, - std::mem::size_of::() as libc::size_t, - ) as *mut PamResponse; - - if resp_array.is_null() { - return 7; // PAM_AUTH_ERR - } - - for i in 0..num_msg as isize { - // Safety: strdup allocates with malloc — PAM will free() the resp strings. - // We dereference password which is valid for the lifetime of authenticate(). - let resp_ptr = resp_array.offset(i); - (*resp_ptr).resp = libc::strdup((*password).as_ptr()); - (*resp_ptr).resp_retcode = 0; - } - - // Safety: resp is a valid pointer provided by PAM - *resp = resp_array; - PAM_SUCCESS } /// Authenticate a user via PAM. diff --git a/src/lockscreen.rs b/src/lockscreen.rs index fa7c8f8..16727f4 100644 --- a/src/lockscreen.rs +++ b/src/lockscreen.rs @@ -47,7 +47,7 @@ pub fn create_lockscreen_window( } }; - let mut fp_listener = FingerprintListener::new(); + let fp_listener = FingerprintListener::new(); let fp_available = config.fingerprint_enabled && fp_listener.is_available(&user.username);