From 265be1d12592f57f9e4efda50503c23c706e8575 Mon Sep 17 00:00:00 2001 From: hmziqagent Date: Tue, 28 Jul 2026 09:25:01 +0200 Subject: [PATCH 1/5] chore: drop stray build artifacts and fix manifest metadata Remove the leaked rust_out/stdin ELF binaries from the working tree and gitignore them at the repo root. Correct the Cargo.toml repository URL to the real remote (freeoxide/wake; the old freeoxide/oxiwake does not resolve) and add the readme and authors fields for crates.io/docs.rs metadata. --- .gitignore | 4 ++++ Cargo.toml | 4 +++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index ad67955..24c2ddb 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,10 @@ target # Contains mutation testing data **/mutants.out*/ +# Stray build artifacts that leaked into the repo root (never tracked). +/rust_out +/stdin + # RustRover # JetBrains specific template is maintained in a separate JetBrains.gitignore that can # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore diff --git a/Cargo.toml b/Cargo.toml index cf01153..202f974 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,9 @@ edition = "2021" rust-version = "1.85" description = "Keep your machine awake where the OS allows it — and tell you when it cannot." license = "MIT" -repository = "https://github.com/freeoxide/oxiwake" +repository = "https://github.com/freeoxide/wake" +readme = "README.md" +authors = ["Freeoxide"] keywords = ["caffeinate", "wake-lock", "systemd", "power", "keep-awake"] categories = ["command-line-utilities"] From 4eb62c529c22869dcadd5fccfdd165c4c8add773 Mon Sep 17 00:00:00 2001 From: hmziqagent Date: Tue, 28 Jul 2026 09:25:01 +0200 Subject: [PATCH 2/5] ci: add GitHub Actions workflow (fmt, clippy, tests, cross-compile) Runs on push to main and pull requests. fmt job gates rustfmt; linux job runs the documented verify matrix (test default + linux-x11, wayland check, both clippy variants); windows-gnu-cross type-checks the Windows backend from Linux; windows job builds and tests on windows-latest MSVC. --- .github/workflows/ci.yml | 73 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6cc16c3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,73 @@ +name: ci + +on: + push: + branches: [main] + pull_request: + +env: + CARGO_TERM_COLOR: always + +jobs: + fmt: + name: rustfmt + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + - run: cargo fmt --check + + linux: + name: linux (test + clippy) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + - uses: Swatinem/rust-cache@v2 + + - name: cargo test (default features) + run: cargo test + + - name: cargo test --features linux-x11 + run: cargo test --features linux-x11 + + # Wayland has no runtime on CI; compile-check the module only. + - name: cargo check --features linux-wayland + run: cargo check --features linux-wayland + + - name: cargo clippy --all-targets + run: cargo clippy --all-targets -- -D warnings + + # The feature model supports a bare lib build; lint it too. + - name: cargo clippy --no-default-features --lib + run: cargo clippy --no-default-features --lib -- -D warnings + + windows-gnu-cross: + name: windows-gnu (cross check) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + targets: x86_64-pc-windows-gnu + - uses: Swatinem/rust-cache@v2 + # `check` type-checks the cfg(windows) backend without needing a MinGW + # linker to actually link a binary. + - name: cargo check --target x86_64-pc-windows-gnu + run: cargo check --target x86_64-pc-windows-gnu + + windows: + name: windows (MSVC test) + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + # Builds the cfg(windows) backend for real. The daemon_lifecycle suite + # drives run_daemon via MockBackend, so it runs cross-platform. + - name: cargo test + run: cargo test From 2a7cf46564b9b6db7623d377d8fee23b73de9250 Mon Sep 17 00:00:00 2001 From: hmziqagent Date: Tue, 28 Jul 2026 09:25:01 +0200 Subject: [PATCH 3/5] docs: add CHANGELOG and CONTRIBUTING; fix README state paths Fix the README state-path listing to match the code (pending.{pid}.json, oxiwake.lock) for both Linux and Windows, add an Installation section, and a CI badge. Add CHANGELOG.md (Keep a Changelog) with the v0.1.0 entry and CONTRIBUTING.md documenting the verify matrix, feature model, and the shared CARGO_TARGET_DIR convention. --- CHANGELOG.md | 62 ++++++++++++++++++++++++++ CONTRIBUTING.md | 116 ++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 28 +++++++++++- 3 files changed, 204 insertions(+), 2 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..378a83f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,62 @@ +# Changelog + +All notable changes to Oxiwake are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +_Nothing yet._ + +## [0.1.0] - 2026-07-21 + +Initial public release. + +### Added + +- **CLI** (`ow`): the verbs `on`, `off`, `toggle`, `status`, and `doctor`, plus + the global flags `--json` / `-j` and `--verbose` / `-v` (repeatable). The + `on` / `toggle` verbs take `--display` (also keep the display on), + `--aggressive-lid` (add `handle-lid-switch`; usually needs privilege), and + `--reason `. +- **Linux backends** (priority order): `systemd-logind` D-Bus inhibitor (main), + XDG Desktop Portal inhibit, GNOME SessionManager, KDE PowerDevil / Solid, and + `org.freedesktop.ScreenSaver` (idle-only). All ship on by default and ride on + raw `zbus` (fully synchronous, no async runtime). X11 XScreenSaver / DPMS is + available behind the `linux-x11` feature, and Wayland idle-inhibit behind the + `linux-wayland` feature. +- **Windows backends**: `PowerCreateRequest` + `PowerSetRequest` (preferred) with + a `SetThreadExecutionState` fallback, via `windows-sys`. +- **Daemon model**: a small detached daemon holds the RAII `WakeGuard` for the + lock's whole lifetime (the guard's `Drop` closes the logind inhibitor FD on + Linux / clears the power request on Windows), so the lock's lifetime is tied + to the guard's — leak-safe by construction. A singleton advisory lock + (`oxiwake.lock`) ensures two racing `ow on` invocations can never both reach + OS-lock acquisition, and a per-invocation `pending.{pid}.json` hand-off + (keyed on the CLI's own pid) lets concurrent `ow on` invocations never + clobber each other's request. +- **`ow doctor`**: honest environment + per-backend diagnostics. Each backend + reports `supported` (compiled in) and `available` (reachable now), plus the + **guarantees** it cannot promise — e.g. that a logind `block` on + `shutdown`/`sleep`/`handle-*` typically needs a PolicyKit privilege (`idle` + is the one routinely allowed unprivileged), and that on Windows Modern + Standby / battery system/execution requests can be terminated ~5 minutes + after the sleep timeout and user-initiated sleep clears requests. +- Cross-platform design docs in [`docs/setup.md`](docs/setup.md). + +### Known limitations + +- **Wayland acquire is unavailable.** The `zwp_idle_inhibit_manager_v1` protocol + requires a `wl_surface`, and a headless daemon has none, so the + `linux-wayland` feature compiles and is probed by `ow doctor` but does not + provide an acquire path. Wayland users should rely on the D-Bus backends + (logind / portal / ScreenSaver). +- **Backend selection falls back across the priority list.** Oxiwake tries each + compiled-in Linux backend in priority order and uses the first that acquires + the lock; it does not (and should not) special-case distro names. +- `crates.io` publication is planned but not yet done; v0.1.0 is build-from-source + (see the README's [Installation](README.md#installation) section). + +[Unreleased]: https://github.com/freeoxide/wake/compare/v0.1.0...HEAD +[0.1.0]: https://github.com/freeoxide/wake/releases/tag/v0.1.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..a99d7b3 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,116 @@ +# Contributing to Oxiwake + +Thanks for digging in. Oxiwake is a cross-platform "keep awake" CLI/daemon +(`ow`) written in Rust 2021 (MSRV **1.85**). This file covers how to build, +how to run the full verification matrix, the feature model, the shared +target-directory convention, where the architecture lives, and the commit +style the repo uses. + +## Building & verifying + +**Do not** check in a local `target/` directory. This repo uses a shared +target directory so concurrent builds (and multiple agents) don't fight: + +```bash +export CARGO_TARGET_DIR=~/.cache/cargo-target +``` + +Put that in your shell profile so every `cargo` invocation shares one build +cache. There is deliberately no per-repo `target/`. + +The full verification matrix (please run **all** of these before pushing): + +```bash +# Formatting — must be clean. +cargo fmt --check + +# Lint the default feature set (all D-Bus backends). +cargo clippy --all-targets -- -D warnings + +# Lint with D-Bus backends OFF — guards against code that only compiles +# when the default features are on (e.g. a stray `mut`, an unused import). +cargo clippy --no-default-features --lib -- -D warnings + +# Tests. +cargo test +cargo test --features linux-x11 + +# The Wayland feature must at least type-check (it has no acquire path; see +# "Known limitations" — a check, not a test, is the right bar). +cargo check --features linux-wayland + +# The Windows backend + named-pipe transport type-check from Linux without a +# Windows toolchain. (Final linking needs mingw-w64; `cargo check` exercises +# the full windows-sys type-check without it.) +rustup target add x86_64-pc-windows-gnu +cargo check --target x86_64-pc-windows-gnu +``` + +Every one of these is expected to pass with zero warnings under `-D warnings`. +CI runs the same matrix, so if it's green locally it'll be green upstream. + +### Runtime notes for tests + +This host (and CI) has **no Polkit, no X server, no Windows runtime**. Every +test is therefore either pure logic or build-gated behind the correct +`cfg(feature = "...")` / `cfg(target_os = "...")` and **never** requires a live +D-Bus / X / Windows session. Daemon and acquire paths are exercised through a +`MockBackend`; platform-specific code lives behind the right `cfg`. If you add +a test that needs a real session, you've written the wrong test. + +## Feature model + +Oxiwake's feature model is "backend support", mirroring +[`docs/setup.md`](docs/setup.md): + +- **Default** (`cargo build`): every D-Bus backend on Linux — `linux-logind`, + `linux-portal`, `linux-screensaver`, `linux-gnome`, `linux-kde` — all riding + on `zbus` (fully synchronous, no async runtime, no extra deps). +- **`linux-x11`** (opt-in): X11 XScreenSaver / DPMS via `x11rb`. Pulls a + heavier native dep, so it's off by default. +- **`linux-wayland`** (opt-in): Wayland idle-inhibit via `wayland-client` + + `wayland-protocols`. Compiles and is probed by `ow doctor`, but has no + acquire path — a headless daemon has no `wl_surface`. See "Known + limitations" in the [CHANGELOG](CHANGELOG.md). + +Windows backends are unconditional on Windows (gated by `cfg(windows)`, not a +feature). Do not add a feature that re-points a backend to a different crate +without updating `docs/setup.md` to match. + +## Where the architecture lives + +Before changing anything load-bearing, read: + +- **[`src/daemon.rs`](src/daemon.rs)** — the module-level doc-comments are the + authoritative description of the lock-ownership invariant (the RAII guard is + held for the daemon's whole lifetime; its `Drop` closes the logind FD / + clears the power request), the singleton file lock, and the per-invocation + `pending.{pid}.json` hand-off. +- **[`docs/setup.md`](docs/setup.md)** — the backend strategy, priority order, + dependency rationale, state/IPC layout, and the `ow doctor` checklists. +- **README "Project layout"** — a one-line-per-file map of `src/`. + +If you change the backend priority order, the runtime file layout, or any D-Bus +signature, update all three (code doc-comments, `docs/setup.md`, README) so +they stay in sync. + +## Commit style + +This repo uses [Conventional Commits](https://www.conventionalcommits.org/). +See `git log --oneline` for the established prefixes: + +- `feat:` — a new capability (e.g. `feat: implement Oxiwake keep-awake CLI/daemon`) +- `fix:` — a bug fix, often scoped (`fix(daemon): ...`, `fix(wayland): ...`, + `fix(windows): ...`, `fix(backend): ...`, `fix(platform): ...`) +- `docs:` — documentation only (`docs(setup): ...`) +- `test:` — test additions/fixes (`test(x11): ...`) +- `chore:` — tooling, CI, housekeeping + +The scope, when present, names the area (`daemon`, `platform`, `backend`, +`wayland`, `x11`, `windows`, `setup`, …). Keep the subject line imperative and +under ~72 characters; put the "why" in the body. + +## License + +MIT. By contributing you agree your contributions are licensed under the same +terms. diff --git a/README.md b/README.md index f02ee46..1e406a9 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # Oxiwake +[![CI](https://github.com/freeoxide/wake/actions/workflows/ci.yml/badge.svg)](https://github.com/freeoxide/wake/actions/workflows/ci.yml) + > Oxiwake keeps your machine awake where the OS allows it — and tells you when it cannot. Oxiwake (`ow`) is a cross-platform "keep awake" CLI/daemon. It holds a wake lock @@ -51,10 +53,17 @@ the core, leak-safe design. State lives in: ``` -Linux: $XDG_RUNTIME_DIR/oxiwake/{state.json, oxiwake.sock, pending.json} -Windows: %LOCALAPPDATA%\Freeoxide\Oxiwake\... +Linux: $XDG_RUNTIME_DIR/oxiwake/{state.json, oxiwake.sock, pending.{pid}.json, oxiwake.lock} +Windows: %LOCALAPPDATA%\Freeoxide\Oxiwake\{state.json, oxiwake.sock, pending.{pid}.json, oxiwake.lock} ``` +`state.json` is the persistent lock snapshot; `pending.{pid}.json` is the +per-invocation (pid-keyed) request hand-off from CLI to daemon; `oxiwake.lock` +is the singleton advisory lock (`flock` on Linux, `LockFileEx` on Windows) the +daemon holds for its whole lifetime so two racing `ow on` invocations can never +both reach OS-lock acquisition. All of these live in a tmpfs and are wiped on +logout/reboot, which is fine for transient lock state. + ## Usage ``` @@ -93,6 +102,21 @@ Options: If `ow on` cannot take the lock (e.g. the OS refuses), it reports the **real** reason rather than hanging. +## Installation + +Oxiwake is at **v0.1.0**. There is no `crates.io` package yet (a publish is +planned), so the primary path today is **build from source**, covered in +[Building](#building) below. If you have a Rust toolchain, the quickest one-off +install is from git: + +```bash +cargo install --git https://github.com/freeoxide/wake +``` + +Prebuilt binaries for each release are the intended future path and will be +attached to [GitHub Releases](https://github.com/freeoxide/wake/releases) once +available. Until then, build from source. + ## Building ```bash From 1bb5473b5bd8f10b263a22678f361b8e003e74d7 Mon Sep 17 00:00:00 2001 From: hmziqagent Date: Tue, 28 Jul 2026 09:25:01 +0200 Subject: [PATCH 4/5] feat(backend): add automatic fallback and surface doctor probe errors pick_and_acquire walks supported_backends() in priority order, skipping BackendUnavailable/AcquireFailed and propagating other errors, so a host whose primary backend refuses the lock falls through to weaker ones. The daemon selects via this path with the singleton lock taken before acquire (split into run_daemon_impl). doctor_all() now emits an available=false row for a backend whose doctor() errors instead of dropping it silently. doctor.rs splits os_release parsing from file I/O for unit testing. --- src/backend/mod.rs | 426 +++++++++++++++++++++++++++++++++++++++++++-- src/daemon.rs | 173 +++++++++++------- src/doctor.rs | 231 +++++++++++++++++++++++- 3 files changed, 757 insertions(+), 73 deletions(-) diff --git a/src/backend/mod.rs b/src/backend/mod.rs index 081d533..75c46d6 100644 --- a/src/backend/mod.rs +++ b/src/backend/mod.rs @@ -3,11 +3,13 @@ //! //! Oxiwake thinks in *backends*, not *distros* (see `docs/setup.md`). Each //! backend is a small module behind a platform/feature gate; this module wires -//! them into a single ordered list and exposes the three call sites the rest of -//! the crate needs: +//! them into a single ordered list and exposes the call sites the rest of the +//! crate needs: //! //! - [`supported_backends`] — every compiled-in backend, in priority order; -//! - [`pick`] — the strongest backend compiled into this build; +//! - [`pick`] — the strongest backend compiled into this build (single-try); +//! - [`pick_and_acquire`] — the daemon's real path: walk the priority list +//! and acquire the first backend that succeeds (automatic fallback); //! - [`doctor_all`] — [`WakeBackend::doctor`] over every compiled-in backend. //! //! Priority order is the "Linux auto backend" / "Windows auto backend" list @@ -102,10 +104,9 @@ pub fn supported_backends() -> Vec> { /// /// This returns the first entry of [`supported_backends`]; it does *not* probe /// availability (that happens at [`WakeBackend::acquire`] time, which maps an -/// unreachable service to [`OxiwakeError::BackendUnavailable`]). Single-try -/// selection matches the phased plan in `docs/setup.md` (ship the primary -/// backend first; automatic fallback across the priority list is a later -/// phase). +/// unreachable service to [`OxiwakeError::BackendUnavailable`]). Selection is +/// single-try — for the daemon's acquire-with-fallback path, prefer +/// [`pick_and_acquire`], which walks the whole priority list. pub fn pick(_req: &WakeRequest) -> Result> { supported_backends() .into_iter() @@ -116,13 +117,418 @@ pub fn pick(_req: &WakeRequest) -> Result> { }) } +/// Try to acquire the wake lock by walking [`supported_backends`] in priority +/// order until one succeeds — automatic fallback. +/// +/// This is the daemon's real selection path. For each compiled-in backend it +/// calls [`WakeBackend::acquire`]: +/// +/// - [`OxiwakeError::BackendUnavailable`] (no D-Bus, no display, ...) is logged +/// at `debug` and the next backend is tried — the environment simply is not +/// there. +/// - [`OxiwakeError::AcquireFailed`] (the OS refused the lock, e.g. a PolicyKit +/// denial) is logged at `warn` and the next backend is tried — the backend +/// was reachable but could not take the lock, so a weaker fallback may still +/// succeed. +/// - Any *other* error (e.g. an unexpected D-Bus decode failure) propagates +/// immediately: it is not a "try the next one" condition and hiding it behind +/// a fallback would mask a real bug. +/// +/// If every backend is unavailable/fails, the **last** error is returned (so the +/// caller surfaces the most informative failure — typically an `AcquireFailed` +/// from the strongest backend that was actually reachable). If no backend is +/// compiled in at all, a `BackendUnavailable` naming `"none"` is returned, as in +/// [`pick`]. On success, returns both the chosen backend (so the daemon can +/// later call `status()`) and the RAII guard that owns the OS lock. +pub fn pick_and_acquire( + req: &WakeRequest, +) -> Result<(Box, Box)> { + acquire_with_fallback(supported_backends(), req) +} + +/// The pure selection core of [`pick_and_acquire`]: walk `backends` in order, +/// skipping on [`OxiwakeError::BackendUnavailable`] (debug) and +/// [`OxiwakeError::AcquireFailed`] (warn), propagating any *other* error +/// immediately, and returning the last error if none succeed. +/// +/// Split out from [`pick_and_acquire`] so the fallback logic is unit-testable +/// with mock backends — [`supported_backends`] can only return the real, +/// platform-gated backends, which need a live session. +fn acquire_with_fallback( + backends: Vec>, + req: &WakeRequest, +) -> Result<(Box, Box)> { + let mut last_err: Option = None; + for backend in backends { + let name = backend.name(); + match backend.acquire(req) { + Ok(guard) => { + if last_err.is_some() { + tracing::info!( + backend = name, + "acquired lock after falling back from a stronger backend" + ); + } else { + tracing::debug!(backend = name, "acquired lock"); + } + return Ok((backend, guard)); + } + // "Not there" — quietly try the next backend. + Err(e @ OxiwakeError::BackendUnavailable { .. }) => { + tracing::debug!(backend = name, "backend unavailable, trying next"); + last_err = Some(e); + } + // "There but refused" — louder, but still fall through: a weaker + // backend may yet succeed (e.g. logind denied, but the X11 idle + // reset still works). + Err(e @ OxiwakeError::AcquireFailed { .. }) => { + tracing::warn!( + backend = name, + "backend could not acquire the lock, trying next" + ); + last_err = Some(e); + } + // Unexpected error (decode failure, I/O, ...): do not mask it with a + // fallback — surface it immediately so a real bug is not hidden. + Err(e) => { + tracing::error!(backend = name, error = %e, "unexpected backend error"); + return Err(e); + } + } + } + // Nothing succeeded. Surface the most informative failure we saw (the last + // one — typically an `AcquireFailed` from the strongest reachable backend); + // if the list was empty, this is the same "no backend compiled in" error + // [`pick`] returns. + Err( + last_err.unwrap_or_else(|| OxiwakeError::BackendUnavailable { + backend: "none", + reason: "no wake backend is compiled into this build (see `ow doctor`)".to_string(), + }), + ) +} + /// Run [`WakeBackend::doctor`] over every compiled-in backend. /// -/// A backend whose `doctor()` itself errors is dropped (the rest still report), -/// so one broken probe never blanks out the whole table. +/// A backend whose `doctor()` itself errors is **not** dropped silently: the +/// error is logged with `tracing::warn!` and the backend still gets a row in the +/// report with `available = false` and a note carrying the error message, so one +/// broken probe never blanks out the whole table and the user can always see +/// *which* backend failed and why. The report uses the existing +/// [`DoctorReport`] shape (no new variants): `supported` is `true` (the backend +/// *is* compiled in), `guarantees` is empty, and the single note describes the +/// probe failure. pub fn doctor_all() -> Vec { supported_backends() .into_iter() - .filter_map(|b| b.doctor().ok()) + .map(|b| { + let name = b.name(); + match b.doctor() { + Ok(report) => report, + Err(e) => { + tracing::warn!(backend = name, error = %e, "backend doctor probe failed"); + DoctorReport { + backend: name.to_string(), + supported: true, + available: false, + guarantees: Vec::new(), + notes: vec![format!("doctor probe failed: {e}")], + } + } + } + }) .collect() } + +#[cfg(test)] +mod tests { + //! Pure-logic tests for the fallback selection (`acquire_with_fallback`) + //! and the doctor-error tolerance (`doctor_all`) using mock backends. + //! + //! These never touch a real D-Bus / X / Windows session: each mock backend + //! returns canned results from `acquire` / `doctor`, so the selection and + //! reporting logic is exercised purely. + + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + + use super::acquire_with_fallback; + use crate::error::{OxiwakeError, Result}; + use crate::model::{ + DoctorReport, WakeBackend, WakeGuard, WakeMode, WakeRequest, WakeStatus, WakeTarget, + }; + + /// A mock backend whose `acquire` returns a preloaded result, and which + /// counts how many times `acquire` was called (so a test can assert that + /// fallback stopped as soon as a backend succeeded). + struct MockBackend { + name: &'static str, + acquire_result: AcquireResult, + acquire_calls: Arc, + /// If set, `doctor()` fails with this error; otherwise it succeeds with + /// a trivially-available report. Stored as a tag (not a prebuilt + /// `OxiwakeError`) so `doctor()` can construct a fresh owned error on + /// each call — `OxiwakeError` is not `Clone`, so we cannot clone a + /// stored `Result`. + doctor_err: Option, + } + + /// What `MockBackend::doctor()` should fail with. + enum DoctorErr { + AcquireFailed(&'static str), + } + + enum AcquireResult { + Ok, + Unavailable(&'static str), + Failed(&'static str), + OtherErr(&'static str), + } + + impl MockBackend { + fn new(name: &'static str, acquire_result: AcquireResult) -> (Self, Arc) { + let calls = Arc::new(AtomicUsize::new(0)); + ( + MockBackend { + name, + acquire_result, + acquire_calls: Arc::clone(&calls), + doctor_err: None, + }, + calls, + ) + } + } + + impl WakeBackend for MockBackend { + fn name(&self) -> &'static str { + self.name + } + fn supported(&self) -> bool { + true + } + fn acquire(&self, _req: &WakeRequest) -> Result> { + self.acquire_calls.fetch_add(1, Ordering::SeqCst); + match &self.acquire_result { + AcquireResult::Ok => Ok(Box::new(MockGuard { backend: self.name })), + AcquireResult::Unavailable(reason) => Err(OxiwakeError::BackendUnavailable { + backend: self.name, + reason: (*reason).to_string(), + }), + AcquireResult::Failed(reason) => Err(OxiwakeError::AcquireFailed { + backend: self.name, + reason: (*reason).to_string(), + }), + AcquireResult::OtherErr(msg) => Err(OxiwakeError::Other((*msg).to_string())), + } + } + fn status(&self) -> Result { + Ok(WakeStatus { + backend: self.name.to_string(), + targets: vec![WakeTarget::Idle], + mode: WakeMode::Block, + display: false, + }) + } + fn doctor(&self) -> Result { + match &self.doctor_err { + None => Ok(DoctorReport { + backend: self.name.to_string(), + supported: true, + available: true, + guarantees: Vec::new(), + notes: Vec::new(), + }), + Some(DoctorErr::AcquireFailed(reason)) => Err(OxiwakeError::AcquireFailed { + backend: self.name, + reason: (*reason).to_string(), + }), + } + } + } + + struct MockGuard { + backend: &'static str, + } + + impl WakeGuard for MockGuard { + fn backend(&self) -> &'static str { + self.backend + } + } + + /// Build the priority list from a vector of `(name, result)` specs, in the + /// given order. Returns the boxed backends plus their call counters. + fn priority( + specs: Vec<(&'static str, AcquireResult)>, + ) -> (Vec>, Vec>) { + let mut backends: Vec> = Vec::new(); + let mut counters = Vec::new(); + for (name, result) in specs { + let (b, c) = MockBackend::new(name, result); + backends.push(Box::new(b)); + counters.push(c); + } + (backends, counters) + } + + fn req() -> WakeRequest { + WakeRequest::default_linux() + } + + // --- acquire_with_fallback ---------------------------------------------- + + #[test] + fn fallback_picks_first_when_it_succeeds() { + let (backends, counters) = priority(vec![ + ("strong", AcquireResult::Ok), + ("weak", AcquireResult::Ok), + ]); + let (chosen, _guard) = acquire_with_fallback(backends, &req()).expect("should succeed"); + assert_eq!(chosen.name(), "strong"); + // The second backend must NOT be tried once the first succeeded. + assert_eq!(counters[0].load(Ordering::SeqCst), 1); + assert_eq!(counters[1].load(Ordering::SeqCst), 0); + } + + #[test] + fn fallback_skips_unavailable_to_next_success() { + let (backends, counters) = priority(vec![ + ("strong", AcquireResult::Unavailable("no logind")), + ("weak", AcquireResult::Ok), + ]); + let (chosen, _guard) = acquire_with_fallback(backends, &req()).expect("should succeed"); + assert_eq!(chosen.name(), "weak"); + assert_eq!(counters[0].load(Ordering::SeqCst), 1); + assert_eq!(counters[1].load(Ordering::SeqCst), 1); + } + + #[test] + fn fallback_skips_acquire_failed_to_next_success() { + // The whole point of fallback: a PolicyKit denial on the strongest + // backend must not prevent a weaker backend from taking over. + let (backends, counters) = priority(vec![ + ("strong", AcquireResult::Failed("polkit denied")), + ("weak", AcquireResult::Ok), + ]); + let (chosen, _guard) = acquire_with_fallback(backends, &req()).expect("should succeed"); + assert_eq!(chosen.name(), "weak"); + assert_eq!(counters[0].load(Ordering::SeqCst), 1); + assert_eq!(counters[1].load(Ordering::SeqCst), 1); + } + + #[test] + fn fallback_returns_last_error_when_all_fail() { + let (backends, _counters) = priority(vec![ + ("strong", AcquireResult::Unavailable("no logind")), + ("weak", AcquireResult::Failed("x11 refused")), + ]); + let err = acquire_with_fallback(backends, &req()) + .err() + .expect("should fail"); + // The most informative failure — the last one — is surfaced. + match err { + OxiwakeError::AcquireFailed { backend, reason } => { + assert_eq!(backend, "weak"); + assert!(reason.contains("x11 refused")); + } + other => panic!("expected AcquireFailed from the last backend, got {other:?}"), + } + } + + #[test] + fn fallback_returns_unavailable_when_all_unavailable() { + let (backends, _counters) = priority(vec![ + ("strong", AcquireResult::Unavailable("a")), + ("weak", AcquireResult::Unavailable("b")), + ]); + let err = acquire_with_fallback(backends, &req()) + .err() + .expect("should fail"); + assert!( + matches!(err, OxiwakeError::BackendUnavailable { .. }), + "expected BackendUnavailable, got {err:?}" + ); + } + + #[test] + fn fallback_returns_none_unavailable_for_empty_list() { + // No backend compiled in: the same "none" error pick() returns. + let err = acquire_with_fallback(Vec::new(), &req()) + .err() + .expect("empty list should fail"); + match err { + OxiwakeError::BackendUnavailable { backend, reason } => { + assert_eq!(backend, "none"); + assert!(reason.contains("no wake backend")); + } + other => panic!("expected BackendUnavailable, got {other:?}"), + } + } + + #[test] + fn fallback_propagates_unexpected_error_immediately() { + // A non-fallback error (e.g. decode failure) must NOT be masked by + // trying the next backend — it surfaces right away. + let (backends, counters) = priority(vec![ + ("strong", AcquireResult::OtherErr("decode boom")), + ("weak", AcquireResult::Ok), + ]); + let err = acquire_with_fallback(backends, &req()) + .err() + .expect("should surface the error"); + match err { + OxiwakeError::Other(msg) => assert!(msg.contains("decode boom")), + other => panic!("expected Other, got {other:?}"), + } + // The second backend was NOT tried. + assert_eq!(counters[1].load(Ordering::SeqCst), 0); + } + + #[test] + fn fallback_tries_every_backend_when_all_skip() { + // Sanity: every backend gets exactly one acquire call when none succeed. + let (backends, counters) = priority(vec![ + ("a", AcquireResult::Unavailable("")), + ("b", AcquireResult::Failed("")), + ("c", AcquireResult::Unavailable("")), + ]); + let _ = acquire_with_fallback(backends, &req()); + for c in &counters { + assert_eq!(c.load(Ordering::SeqCst), 1, "every backend tried once"); + } + } + + // --- doctor_all error tolerance ----------------------------------------- + + /// Directly verify the error-tolerance shape: a backend whose `doctor()` + /// errors still yields a row with available=false and a note. We can't route + /// a mock through `doctor_all` (it builds real backends, which depend on a + /// live session), so we replicate the exact mapping `doctor_all` uses on Err + /// and assert its shape. This keeps the test hermetic. + #[test] + fn doctor_error_mapping_shape_matches_doctor_all() { + let (b, _) = MockBackend::new("boom-backend", AcquireResult::Ok); + let mut mock = b; + mock.doctor_err = Some(DoctorErr::AcquireFailed("probe exploded")); + // Mirror exactly what doctor_all does on Err: + let name = mock.name(); + let report = match mock.doctor() { + Ok(r) => r, + Err(e) => DoctorReport { + backend: name.to_string(), + supported: true, + available: false, + guarantees: Vec::new(), + notes: vec![format!("doctor probe failed: {e}")], + }, + }; + assert_eq!(report.backend, "boom-backend"); + assert!(report.supported); + assert!(!report.available); + assert!(report.notes.len() == 1); + assert!(report.notes[0].contains("doctor probe failed")); + assert!(report.notes[0].contains("probe exploded")); + assert!(report.guarantees.is_empty()); + } +} diff --git a/src/daemon.rs b/src/daemon.rs index 53d9078..47ad002 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -9,13 +9,18 @@ //! //! # Lock-ownership invariant //! -//! The daemon's [`WakeGuard`](crate::model::WakeGuard) is held by -//! [`run_daemon`] for the **entire** duration of -//! [`platform::bind_and_serve`](crate::platform::bind_and_serve). The guard is -//! dropped only when `run_daemon` returns — on a clean `Stop`, on a fatal -//! serve error, or on process exit. Its `Drop` impl closes the logind FD / -//! clears the power request, so the OS lock is released exactly when the -//! daemon stops, never sooner, and never leaked. +//! The daemon's [`WakeGuard`](crate::model::WakeGuard) is held for the +//! **entire** duration of +//! [`platform::bind_and_serve`](crate::platform::bind_and_serve): in the real +//! entry point ([`daemon_main`] → [`backend::pick_and_acquire`]) the guard is +//! produced by the fallback acquire, and in the test entry point ([`run_daemon`]) +//! it comes from the injected backend. Both paths funnel into `run_daemon_impl`, +//! which binds the guard into the frame for the whole serve — and, critically, +//! takes the singleton file lock *before* acquiring, so a racing double `ow on` +//! can never take a second OS lock. The guard is dropped only when that function +//! returns — on a clean `Stop`, on a fatal serve error, or on process exit. Its +//! `Drop` impl closes the logind FD / clears the power request, so the OS lock +//! is released exactly when the daemon stops, never sooner, and never leaked. //! //! # The per-invocation pending hand-off //! @@ -71,15 +76,15 @@ struct PendingRequest { /// The `ow __daemon` entry point. /// /// Resolves the runtime paths, reads `pending.json` to recover the request -/// and timestamp the CLI chose, and hands them to [`run_daemon`]. The pending -/// file is consumed by [`run_daemon`] itself (it removes it once the lock is -/// live), so on success it does not linger. +/// and timestamp the CLI chose, and hands them to the daemon serve loop. The +/// pending file is consumed up front (it is removed as soon as the request is +/// in memory), so on success it does not linger. /// /// # Errors /// /// [`OxiwakeError::State`] if the runtime directory cannot be resolved or -/// `pending.json` is missing/unparseable; otherwise whatever [`run_daemon`] -/// returns. +/// `pending.json` is missing/unparseable; otherwise whatever the serve loop +/// (or [`backend::pick_and_acquire`]) returns. pub fn daemon_main(pending_path: &std::path::Path) -> Result<()> { // Wrap the real work so that, on any startup failure, we publish the error // to a `startup_error` file the spawning CLI can read. The detached daemon's @@ -110,16 +115,64 @@ fn daemon_main_inner(pending_path: &std::path::Path) -> Result<()> { // removing it now keeps the runtime dir clean for the daemon's whole run and // frees this per-invocation name. let _ = std::fs::remove_file(pending_path); - let backend = backend::pick(&pending.request)?; - run_daemon(backend, &pending.request, pending.started_unix) + // Run the daemon, acquiring the lock via [`backend::pick_and_acquire`]: + // automatic fallback across the priority list (a host without systemd-logind + // falls through to the session/display backends). The acquire happens AFTER + // the singleton lock is taken inside `run_daemon_impl`, so a racing double + // `ow on` can never take a second OS lock. + run_daemon_impl( + &pending.request, + pending.started_unix, + backend::pick_and_acquire, + ) } /// Run the daemon loop: take the lock, publish state, serve until `Stop`. /// -/// This is the heart of the daemon and the keeper of the lock-ownership -/// invariant. The guard returned by [`backend::pick`] lives in the closure -/// passed to [`platform::bind_and_serve`](crate::platform::bind_and_serve) and -/// is dropped only when this function returns. +/// This is the unit-testable entry point: it takes a single injected backend +/// (so a `MockBackend` can drive the singleton → acquire → serve → drop → +/// cleanup lifecycle without a live session) and acquires its guard exactly +/// once. The acquire is routed through [`run_daemon_impl`], which takes the +/// singleton lock **before** calling the acquire closure — preserving the +/// "exactly one OS lock across racing double starts" invariant the lifecycle +/// tests assert. +/// +/// The real `ow __daemon` entry point does **not** call this — it uses +/// [`backend::pick_and_acquire`] (via [`daemon_main_inner`]) so a host whose +/// strongest backend is unavailable can fall back to a weaker one. +pub fn run_daemon( + backend: Box, + req: &WakeRequest, + started_unix: u64, +) -> Result<()> { + // The acquire closure captures the injected backend so the mock-driven + // tests exercise the single-backend path. It is called by `run_daemon_impl` + // AFTER the singleton lock is held. + let acquire = move |req: &WakeRequest| { + let guard = backend.acquire(req)?; + Ok::<_, OxiwakeError>((backend, guard)) + }; + run_daemon_impl(req, started_unix, acquire) +} + +/// The shared daemon body: Ping-decline → singleton lock → acquire → serve → +/// cleanup. +/// +/// `acquire` is called only after the singleton file lock is held, so two +/// racing `ow on` invocations can never both acquire an OS wake lock — the +/// singleton loser declines before `acquire` runs. Both call sites pass their +/// own closure: [`run_daemon`] passes one that calls the injected backend's +/// `acquire` (single-try, for unit tests); [`daemon_main_inner`] passes +/// [`backend::pick_and_acquire`] (automatic fallback across the priority list). +/// +/// # Lock-ownership invariant +/// +/// The guard returned by `acquire` lives for the **entire** duration of +/// [`platform::bind_and_serve`](crate::platform::bind_and_serve): it is bound +/// into this frame (`_guard`) and dropped only when this function returns — on +/// a clean `Stop`, a fatal serve error, or process exit. Its `Drop` impl closes +/// the logind FD / clears the power request, so the OS lock is released exactly +/// when the daemon stops, never sooner, and never leaked. /// /// Steps: /// @@ -127,66 +180,63 @@ fn daemon_main_inner(pending_path: &std::path::Path) -> Result<()> { /// 2. Claim the singleton file lock ([`platform::acquire_singleton_lock`]) — /// the atomic mutex guaranteeing only one daemon ever reaches step 3. A /// racing second `ow on` declines here, before it can take a second OS lock -/// or clobber `state.json`. (The backend is injected by the caller so this -/// lifecycle is unit-testable with a mock; the real entry point does the -/// [`backend::pick`].) -/// 3. Acquire the backend's guard (taking the OS wake lock). +/// or clobber `state.json`. +/// 3. Acquire the wake lock via `acquire(req)` (single backend, or fallback). /// 4. Snapshot the backend's [`WakeStatus`] once — the lock's identity does not /// change over the daemon's lifetime, so a single `status()` call is both /// correct and cheap. -/// 5. Write `state.json` so `ow status` works without a live IPC. (The -/// per-invocation `pending.{pid}.json` hand-off was already consumed by -/// [`daemon_main_inner`] before this function runs.) +/// 5. Write `state.json` so `ow status` works without a live IPC. /// 6. Serve: [`platform::bind_and_serve`](crate::platform::bind_and_serve) with /// a closure that owns the guard and status and answers `Ping`, `Status`, /// and `Stop`. /// 7. On return (a `Stop` was dispatched, or the serve loop failed) the guard -/// has already been released (it dropped inside `bind_and_serve`'s return); -/// remove `state.json` so a subsequent `ow status` does not report a stale -/// lock. The singleton lock drops last, at function return. -pub fn run_daemon( - backend: Box, +/// has already been released (`_guard` dropped at function return); remove +/// `state.json` so a subsequent `ow status` does not report a stale lock. +/// The singleton lock drops last, at function return. +fn run_daemon_impl( req: &WakeRequest, started_unix: u64, + acquire: impl FnOnce( + &WakeRequest, + ) -> Result<(Box, Box)>, ) -> Result<()> { let paths = Paths::resolve()?; - // 0. Fast-path decline: if a daemon is *already* serving on this socket, + // 1. Fast-path decline: if a daemon is *already* serving on this socket, // do not take a second inhibitor. This Ping is a cheap optimization — // the authoritative mutex is the singleton lock taken next, which is // what actually closes the racing-double-`ow on` window (a Ping is // check-then-act; the file lock is atomic). if let Ok(reply) = ipc_request(ClientMsg::Ping) { if reply.ok { - // Another daemon is live. Decline to serve; the guard we were - // given is dropped on return (releasing any transient resource). + // Another daemon is live. Decline to serve. return Ok(()); } } - // 0b. The atomic singleton mutex. Two racing `ow on` invocations can both - // pass the Ping above (neither daemon has bound yet); only one can hold - // the singleton file lock (flock on Linux, LockFileEx on Windows). The - // loser declines here — BEFORE acquiring the OS wake lock or writing - // state.json — so a racing double start can never take a second lock or - // clobber the winner's state. Held for the daemon's whole lifetime and - // auto-released on return (or crash). + // 2. The atomic singleton mutex. Two racing `ow on` invocations can both + // pass the Ping above (neither daemon has bound yet); only one can hold + // the singleton file lock (flock on Linux, LockFileEx on Windows). The + // loser declines here — BEFORE acquiring the OS wake lock or writing + // state.json — so a racing double start can never take a second lock or + // clobber the winner's state. Held for the daemon's whole lifetime and + // auto-released on return (or crash). let _singleton = match platform::acquire_singleton_lock(&paths)? { Some(lock) => lock, None => return Ok(()), }; - // 1. Take the lock. `acquire` returns the RAII guard that owns the OS - // resource; keep it alive for the whole serve loop. (The backend is - // injected rather than picked here so the serve/release lifecycle is - // unit-testable with a mock backend — the real entry point, - // [`daemon_main_inner`], does the `backend::pick`.) - let guard = backend.acquire(req)?; + // 3. Take the lock via the injected acquire closure. Because this runs + // AFTER the singleton lock, only the singleton winner ever reaches here, + // so exactly one process ever holds an OS wake lock. `acquire` returns + // the backend (for `status()`) and the RAII guard that owns the OS + // resource. + let (backend, guard) = acquire(req)?; - // 2. Snapshot the lock's identity once. Cheap and stable. + // 4. Snapshot the lock's identity once. Cheap and stable. let status = backend.status()?; - // 3. Publish state for status-without-IPC, then consume the hand-off file. + // 5. Publish state for status-without-IPC. let state = LockState { pid: std::process::id(), backend: guard.backend().to_string(), @@ -195,10 +245,13 @@ pub fn run_daemon( }; LockState::write(&paths, &state)?; - // 4. Serve. The guard is moved into the closure so it lives exactly as - // long as the serve loop; the captured `status` is cheaply cloned per - // reply. The closure returns a `DaemonReply` for each message and the - // platform layer handles framing and the Stop-triggered exit. + // 6. Serve. `guard` (bound above) and `_singleton` are `let`-bound in this + // frame, so Rust drops them at the end of this function — after + // `bind_and_serve` returns — NOT at last use (drop scopes are lexical, + // not use-based). `bind_and_serve` blocks until a `Stop`, so the guard + // stays alive for the entire serve, holding the OS lock until serve + // finishes. The `move` closure captures only the cheaply-cloned `status` + // and `pid`. let pid = std::process::id(); let serve_result = platform::bind_and_serve(&paths, move |msg: ClientMsg| -> DaemonReply { match msg { @@ -208,12 +261,11 @@ pub fn run_daemon( } }); - // 5. Tear down. The OS lock (guard) was already released: it was moved - // into the serve closure and dropped when `bind_and_serve` returned. Now - // clear state.json best-effort so a later `ow status` does not see a - // stale lock — a failure here must not mask the real error from the - // serve loop. The singleton lock (`_singleton`) is released last, when - // it drops at function return. + // 7. Tear down. Now clear state.json best-effort so a later `ow status` + // does not see a stale lock — a failure here must not mask the real + // error from the serve loop. The OS lock (`guard`) and the singleton + // (`_singleton`) are released when this function returns, dropping in + // reverse declaration order: `guard` first, then `_singleton` last. let _ = LockState::remove(&paths); serve_result?; @@ -245,7 +297,10 @@ pub fn ensure_started(req: &WakeRequest, started_unix: u64) -> Result) { /// minimal/odd systems. The format is `KEY=value` (optionally quoted). We prefer /// `PRETTY_NAME`, falling back to `ID` + `VERSION_ID`, exactly as `os-release(5)` /// describes. Some systems ship only `/usr/lib/os-release`, tried as a fallback. +/// +/// The actual parsing is delegated to [`parse_os_release`], which takes the file +/// contents as a `&str`; this wrapper only does the I/O (reading `/etc/os-release` +/// then `/usr/lib/os-release` as a fallback) so the parser is unit-testable +/// without touching the filesystem. #[cfg(target_os = "linux")] fn os_release(out: &mut Vec<(String, String)>) { let text = match std::fs::read_to_string("/etc/os-release") { @@ -130,12 +135,25 @@ fn os_release(out: &mut Vec<(String, String)>) { Err(_) => return, // Not fatal: just no OS row. }, }; + if let Some(name) = parse_os_release(&text) { + out.push(("OS".to_string(), name)); + } +} +/// Pure parser for the `os-release` format (`KEY=value`, optionally quoted). +/// +/// Returns the best human-readable distro name we can synthesize from `contents`: +/// `PRETTY_NAME` when present, otherwise `ID` + `VERSION_ID` joined with a space, +/// or `None` when neither is present. Comments (`#`), blank lines, and trailing +/// whitespace are ignored; unrecognized keys are skipped. This is the testable +/// core of [`os_release`] — it does no I/O. +#[cfg(target_os = "linux")] +fn parse_os_release(contents: &str) -> Option { let mut pretty: Option = None; let mut id: Option = None; let mut version_id: Option = None; - for raw in text.lines() { + for raw in contents.lines() { let line = raw.trim(); if line.is_empty() || line.starts_with('#') { continue; @@ -153,7 +171,7 @@ fn os_release(out: &mut Vec<(String, String)>) { } if let Some(name) = pretty { - out.push(("OS".to_string(), name)); + Some(name) } else { // Synthesize something useful from ID (+ VERSION_ID) when there is no // human-readable PRETTY_NAME. @@ -164,8 +182,10 @@ fn os_release(out: &mut Vec<(String, String)>) { if let Some(v) = version_id { parts.push(v); } - if !parts.is_empty() { - out.push(("OS".to_string(), parts.join(" "))); + if parts.is_empty() { + None + } else { + Some(parts.join(" ")) } } } @@ -422,3 +442,206 @@ fn power(out: &mut Vec<(String, String)>) { .to_string(), )); } + +#[cfg(test)] +#[cfg(target_os = "linux")] +mod tests { + //! Pure-logic tests for the `os-release` parser. + //! + //! These exercise [`parse_os_release`] and [`unquote`] directly with `&str` + //! inputs — they never touch the filesystem, so they run anywhere the + //! `linux` parser is compiled (including hosts with no `/etc/os-release`). + + use super::{parse_os_release, unquote}; + + // --- unquote ------------------------------------------------------------- + + #[test] + fn unquote_strips_double_quotes() { + assert_eq!(unquote("\"Debian GNU/Linux 12\""), "Debian GNU/Linux 12"); + } + + #[test] + fn unquote_strips_single_quotes() { + assert_eq!(unquote("'Debian GNU/Linux 12'"), "Debian GNU/Linux 12"); + } + + #[test] + fn unquote_passes_through_unquoted() { + assert_eq!(unquote("debian"), "debian"); + assert_eq!(unquote("12.0"), "12.0"); + } + + #[test] + fn unquote_keeps_embedded_equals() { + // An `=` inside the value is not special to unquote; only the first + // `=` splits key from value (handled by parse_os_release). + assert_eq!(unquote("\"a=b\""), "a=b"); + } + + #[test] + fn unquote_trims_surrounding_whitespace_first() { + assert_eq!(unquote(" \"trimmed\" "), "trimmed"); + assert_eq!(unquote(" bare "), "bare"); + } + + #[test] + fn unquote_does_not_strip_mismatched_quotes() { + // Only a matching pair is stripped; a stray quote character survives. + assert_eq!(unquote("\"mixed'"), "\"mixed'"); + assert_eq!(unquote("'mixed\""), "'mixed\""); + } + + #[test] + fn unquote_leaves_short_values_untouched() { + // A single character (or empty) cannot bracket anything. + assert_eq!(unquote("\""), "\""); + assert_eq!(unquote(""), ""); + assert_eq!(unquote("a"), "a"); + } + + #[test] + fn unquote_keeps_internal_quotes() { + // Quotes that are not the outer pair are part of the value. + assert_eq!(unquote("\"it's a 'test'\""), "it's a 'test'"); + } + + // --- parse_os_release ---------------------------------------------------- + + #[test] + fn parse_prefers_pretty_name() { + let text = "\ +NAME=\"Debian GNU/Linux\" +VERSION_ID=\"12\" +PRETTY_NAME=\"Debian GNU/Linux 12 (bookworm)\" +ID=debian +"; + assert_eq!( + parse_os_release(text).as_deref(), + Some("Debian GNU/Linux 12 (bookworm)") + ); + } + + #[test] + fn parse_falls_back_to_id_and_version_id() { + // No PRETTY_NAME: synthesize from ID + VERSION_ID. + let text = "\ +ID=debian +VERSION_ID=12 +"; + assert_eq!(parse_os_release(text).as_deref(), Some("debian 12")); + } + + #[test] + fn parse_falls_back_to_id_only() { + let text = "ID=alpine\n"; + assert_eq!(parse_os_release(text).as_deref(), Some("alpine")); + } + + #[test] + fn parse_falls_back_to_version_id_only() { + let text = "VERSION_ID=\"36\"\n"; + assert_eq!(parse_os_release(text).as_deref(), Some("36")); + } + + #[test] + fn parse_returns_none_when_nothing_known() { + // Neither PRETTY_NAME nor ID/VERSION_ID -> nothing to synthesize. + let text = "\ +NAME=foo +HOME_URL=https://example.com +"; + assert_eq!(parse_os_release(text), None); + } + + #[test] + fn parse_returns_none_for_empty_input() { + assert_eq!(parse_os_release(""), None); + } + + #[test] + fn parse_ignores_comments_and_blank_lines() { + let text = "\ +# This is a comment + + # indented comment + +PRETTY_NAME=\"Real OS\" +"; + assert_eq!(parse_os_release(text).as_deref(), Some("Real OS")); + } + + #[test] + fn parse_handles_trailing_whitespace_and_comments_after_values() { + // os-release(5): values may have trailing whitespace; a trailing inline + // comment is NOT actually spec (the format has no inline comments), but + // we must at least tolerate the trailing whitespace. + let text = "PRETTY_NAME=\"Spaced OS\" \n"; + assert_eq!(parse_os_release(text).as_deref(), Some("Spaced OS")); + } + + #[test] + fn parse_skips_lines_without_equals() { + // A malformed line with no `=` is ignored, not fatal. + let text = "\ +garbage line +PRETTY_NAME=\"Good OS\" +"; + assert_eq!(parse_os_release(text).as_deref(), Some("Good OS")); + } + + #[test] + fn parse_unquotes_values() { + let text = "PRETTY_NAME='Single Quoted OS'\nID=\"dq\"\n"; + assert_eq!(parse_os_release(text).as_deref(), Some("Single Quoted OS")); + } + + #[test] + fn parse_treats_unquoted_values_literally() { + let text = "ID=ubuntu\nVERSION_ID=22.04\n"; + assert_eq!(parse_os_release(text).as_deref(), Some("ubuntu 22.04")); + } + + #[test] + fn parse_takes_last_occurrence_of_duplicate_keys() { + // Mirrors shell `.` sourcing semantics: a later assignment wins. + let text = "\ +PRETTY_NAME=\"First\" +PRETTY_NAME=\"Second\" +"; + assert_eq!(parse_os_release(text).as_deref(), Some("Second")); + } + + #[test] + fn parse_keeps_value_with_embedded_equals() { + // split_once('=') splits only on the first `=`, so an embedded `=` in + // the value survives. + let text = "PRETTY_NAME=\"A=B OS\"\n"; + assert_eq!(parse_os_release(text).as_deref(), Some("A=B OS")); + } + + #[test] + fn parse_keeps_spaces_inside_quotes() { + let text = "PRETTY_NAME=\"My Cool OS\"\n"; + assert_eq!(parse_os_release(text).as_deref(), Some("My Cool OS")); + } + + #[test] + fn parse_trims_key_whitespace() { + // Leading whitespace before the key is part of the raw line and is + // trimmed before the match. + let text = " PRETTY_NAME=\"Indented OS\"\n"; + assert_eq!(parse_os_release(text).as_deref(), Some("Indented OS")); + } + + #[test] + fn parse_ignores_unrelated_keys() { + let text = "\ +NAME=\"Ignored\" +VERSION=\"Ignored too\" +PRETTY_NAME=\"The One\" +ANSI_COLOR=\"0;31\" +"; + assert_eq!(parse_os_release(text).as_deref(), Some("The One")); + } +} From 47ac8e3854741586c64d4090569d969823b603db Mon Sep 17 00:00:00 2001 From: hmziqagent Date: Tue, 28 Jul 2026 09:25:01 +0200 Subject: [PATCH 5/5] test: add backend/paths/CLI coverage and document reserved API Add DoctorReport snapshot tests across every backend, Paths::resolve tests via a pure resolve_under helper, and an end-to-end CLI smoke suite (spawning the real ow binary for help/version/status/doctor). Document the forward-looking but currently-unused API surface (WakeMode::Delay/BlockWeak, DoctorReport::not_compiled, OxiwakeError::AlreadyRunning) as reserved. --- src/backend/gnome.rs | 49 +++++++ src/backend/kde.rs | 52 +++++++ src/backend/logind.rs | 22 +++ src/backend/portal.rs | 55 +++++++ src/backend/screensaver.rs | 49 +++++++ src/backend/wayland.rs | 63 ++++++++ src/backend/windows.rs | 31 ++++ src/backend/x11.rs | 21 +++ src/error.rs | 8 ++ src/model.rs | 14 ++ src/paths.rs | 158 ++++++++++++++++++-- tests/cli_smoke.rs | 288 +++++++++++++++++++++++++++++++++++++ 12 files changed, 802 insertions(+), 8 deletions(-) create mode 100644 tests/cli_smoke.rs diff --git a/src/backend/gnome.rs b/src/backend/gnome.rs index 4b77755..bf06a42 100644 --- a/src/backend/gnome.rs +++ b/src/backend/gnome.rs @@ -198,3 +198,52 @@ fn name_has_owner(conn: &zbus::blocking::Connection, name: &str) -> Result .map_err(|e| OxiwakeError::DbusDecode(format!("NameHasOwner bool: {e}")))?; Ok(owner) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn name_and_supported() { + let b = GnomeBackend::new(); + assert_eq!(b.name(), NAME); + assert_eq!(b.name(), "gnome-session"); + assert!(b.supported()); + // Default() must agree with new() — both are no-I/O handles. + assert_eq!(GnomeBackend.name(), NAME); + } + + #[test] + fn status_reports_suspend_and_idle_block() { + // status() is a static report (no D-Bus): suspend+idle scope, Block mode. + let s = GnomeBackend::new().status().expect("status"); + assert_eq!(s.backend, NAME); + assert_eq!(s.mode, WakeMode::Block); + assert!(!s.display); + assert!(s.targets.contains(&WakeTarget::SystemSleep)); + assert!(s.targets.contains(&WakeTarget::Idle)); + // GNOME SessionManager does not field lid/shutdown here. + assert!(!s.targets.contains(&WakeTarget::LidSwitch)); + } + + #[test] + fn doctor_returns_ok_with_fixed_name_and_guarantees() { + // doctor() probes the session bus; on a host with no session bus (or no + // GNOME service) it must still return Ok with the fixed name/guarantees. + // `available` legitimately varies, so we assert only the stable fields. + let report = GnomeBackend::new().doctor().expect("doctor"); + assert_eq!(report.backend, NAME); + assert!(report.supported); + let joined = report.guarantees.join(" | "); + // The flags bitmask caveat and the session-level (not kernel) caveat + // are the load-bearing guarantees — pin them so `ow doctor` stays honest. + assert!( + joined.contains("4|8") || joined.contains("4 | 8"), + "guarantees must cite the GNOME flags bitmask, got: {joined}" + ); + assert!( + joined.contains("session-level"), + "guarantees must disclose session-level scope, got: {joined}" + ); + } +} diff --git a/src/backend/kde.rs b/src/backend/kde.rs index 5715898..db207e3 100644 --- a/src/backend/kde.rs +++ b/src/backend/kde.rs @@ -193,3 +193,55 @@ fn name_has_owner(conn: &zbus::blocking::Connection, name: &str) -> Result .map_err(|e| OxiwakeError::DbusDecode(format!("NameHasOwner bool: {e}")))?; Ok(owner) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn name_and_supported() { + let b = KdeBackend::new(); + assert_eq!(b.name(), NAME); + assert_eq!(b.name(), "kde-powerdevil"); + assert!(b.supported()); + assert_eq!(KdeBackend.name(), NAME); + } + + #[test] + fn actions_bitmask_is_change_profile_and_screen() { + // KDE AddInhibition actions: ChangeProfile(1) | ChangeScreenSettings(2) = 3. + // Locking the constant keeps the acquire argument honest. + assert_eq!(ACTIONS_CHANGE_PROFILE_AND_SCREEN, 3); + } + + #[test] + fn status_reports_idle_block_only() { + // status() is a static report (no D-Bus): idle-only scope, Block mode. + let s = KdeBackend::new().status().expect("status"); + assert_eq!(s.backend, NAME); + assert_eq!(s.mode, WakeMode::Block); + assert!(!s.display); + assert_eq!(s.targets, vec![WakeTarget::Idle]); + // KDE PowerDevil is desktop policy — must not claim system sleep / lid. + assert!(!s.targets.contains(&WakeTarget::SystemSleep)); + assert!(!s.targets.contains(&WakeTarget::LidSwitch)); + } + + #[test] + fn doctor_returns_ok_with_fixed_name_and_guarantees() { + // doctor() probes the session bus; availability legitimately varies, so + // assert only the stable name/guarantees fields. + let report = KdeBackend::new().doctor().expect("doctor"); + assert_eq!(report.backend, NAME); + assert!(report.supported); + let joined = report.guarantees.join(" | "); + assert!( + joined.contains("1|2") || joined.contains("1 | 2"), + "guarantees must cite the KDE actions bitmask, got: {joined}" + ); + assert!( + joined.contains("desktop power policy") || joined.contains("desktop"), + "guarantees must disclose desktop-policy scope, got: {joined}" + ); + } +} diff --git a/src/backend/logind.rs b/src/backend/logind.rs index ea9d2e2..efbd862 100644 --- a/src/backend/logind.rs +++ b/src/backend/logind.rs @@ -448,4 +448,26 @@ mod tests { Ok(_) => panic!("expected acquire to fail for an empty `what`"), } } + + #[test] + fn doctor_returns_ok_with_fixed_name_and_supported() { + // doctor() opens the system bus; on a host without logind reachable it + // still returns Ok with available=false. The name/supported fields are + // deterministic and pinned here; availability legitimately varies, so + // we do not assert it. + let report = LogindBackend::new().doctor().expect("doctor must be Ok"); + assert_eq!(report.backend, "systemd-logind"); + assert!(report.supported); + // The guarantees snapshot must always carry the privilege + idle + FD + // caveats regardless of whether the bus is reachable. + let joined = report.guarantees.join(" | "); + assert!( + joined.contains("privilege"), + "guarantees must mention privilege, got: {joined}" + ); + assert!( + joined.contains("file descriptor") || joined.contains("FD"), + "guarantees must disclose the FD-lifetime caveat, got: {joined}" + ); + } } diff --git a/src/backend/portal.rs b/src/backend/portal.rs index 3f06f79..99e4d51 100644 --- a/src/backend/portal.rs +++ b/src/backend/portal.rs @@ -314,6 +314,61 @@ fn portal_owned(conn: &Connection) -> Result { Ok(owned) } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn name_and_supported() { + let b = PortalBackend::new(); + assert_eq!(b.name(), BACKEND_NAME); + assert_eq!(b.name(), "xdg-portal"); + assert!(b.supported()); + assert_eq!(PortalBackend.name(), BACKEND_NAME); + } + + #[test] + fn inhibit_flags_are_suspend_and_idle() { + // The fixed flag set passed to portal Inhibit: suspend(4) | idle(8) = 12. + // Locking the constant keeps the acquire argument honest. + assert_eq!(INHIBIT_FLAGS, FLAG_SUSPEND | FLAG_IDLE); + assert_eq!(INHIBIT_FLAGS, 12); + } + + #[test] + fn status_reports_suspend_and_idle_block() { + // status() is a static report (no D-Bus): suspend+idle scope, Block mode. + let s = PortalBackend::new().status().expect("status"); + assert_eq!(s.backend, BACKEND_NAME); + assert_eq!(s.mode, WakeMode::Block); + assert!(!s.display); + assert!(s.targets.contains(&WakeTarget::SystemSleep)); + assert!(s.targets.contains(&WakeTarget::Idle)); + // The portal only offers suspend+idle — never lid/shutdown. + assert!(!s.targets.contains(&WakeTarget::LidSwitch)); + assert!(!s.targets.contains(&WakeTarget::Shutdown)); + } + + #[test] + fn doctor_returns_ok_with_fixed_name_and_guarantees() { + // doctor() probes the session bus; availability legitimately varies, so + // assert only the stable name/guarantees fields. + let report = PortalBackend::new().doctor().expect("doctor"); + assert_eq!(report.backend, BACKEND_NAME); + assert!(report.supported); + let joined = report.guarantees.join(" | "); + // The headline caveat (setup.md §2): session/desktop-level, NOT kernel. + assert!( + joined.contains("session") || joined.contains("desktop"), + "guarantees must disclose session/desktop scope, got: {joined}" + ); + assert!( + joined.contains("kernel"), + "guarantees must disclaim kernel-level blocking, got: {joined}" + ); + } +} + // ---- platform / feature gating ------------------------------------------------- // // Everything above is plain Rust, but the whole module (its public surface and diff --git a/src/backend/screensaver.rs b/src/backend/screensaver.rs index a2f5fa1..e0fe5ed 100644 --- a/src/backend/screensaver.rs +++ b/src/backend/screensaver.rs @@ -174,3 +174,52 @@ fn name_has_owner(conn: &zbus::blocking::Connection, name: &str) -> Result .map_err(|e| OxiwakeError::DbusDecode(format!("NameHasOwner bool: {e}")))?; Ok(owner) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn name_and_supported() { + let b = ScreenSaverBackend::new(); + assert_eq!(b.name(), NAME); + assert_eq!(b.name(), "freedesktop-screensaver"); + assert!(b.supported()); + assert_eq!(ScreenSaverBackend.name(), NAME); + } + + #[test] + fn status_reports_idle_block_only() { + // status() is a static report (no D-Bus): idle-only scope, Block mode. + let s = ScreenSaverBackend::new().status().expect("status"); + assert_eq!(s.backend, NAME); + assert_eq!(s.mode, WakeMode::Block); + assert!(!s.display); + assert_eq!(s.targets, vec![WakeTarget::Idle]); + // Per the freedesktop idle-inhibit spec this backend is idle-only; it + // must not claim to block suspend/lid/shutdown (setup.md §3). + assert!(!s.targets.contains(&WakeTarget::SystemSleep)); + assert!(!s.targets.contains(&WakeTarget::LidSwitch)); + assert!(!s.targets.contains(&WakeTarget::Shutdown)); + } + + #[test] + fn doctor_returns_ok_with_fixed_name_and_guarantees() { + // doctor() probes the session bus; availability legitimately varies, so + // assert only the stable name/guarantees fields. + let report = ScreenSaverBackend::new().doctor().expect("doctor"); + assert_eq!(report.backend, NAME); + assert!(report.supported); + let joined = report.guarantees.join(" | "); + // The idle-only caveat and the explicit "does NOT prevent suspend" line + // are the load-bearing guarantees — pin them so `ow doctor` stays honest. + assert!( + joined.contains("idle"), + "guarantees must disclose idle-only scope, got: {joined}" + ); + assert!( + joined.contains("suspend"), + "guarantees must mention suspend (as not prevented), got: {joined}" + ); + } +} diff --git a/src/backend/wayland.rs b/src/backend/wayland.rs index 8760ad8..1569ace 100644 --- a/src/backend/wayland.rs +++ b/src/backend/wayland.rs @@ -329,3 +329,66 @@ impl Dispatch for WaylandState { let _ = state; } } + +// Pure-logic tests. The whole module is feature-gated above +// (`#![cfg(all(target_os = "linux", feature = "linux-wayland"))]`), and these +// tests touch only the no-I/O surface (`name` / `supported` / `status`), so +// they never need a live Wayland compositor. +#[cfg(test)] +mod tests { + use super::*; + use crate::model::{WakeMode, WakeTarget}; + + #[test] + fn name_and_supported() { + let b = WaylandBackend::new(); + assert_eq!(b.name(), NAME); + assert_eq!(b.name(), "wayland-idle-inhibit"); + assert!(b.supported()); + assert_eq!(WaylandBackend::default().name(), NAME); + } + + #[test] + fn status_reports_idle_block_only() { + // status() is a static report (no compositor I/O): idle-only, Block. + let s = WaylandBackend::new().status().expect("status"); + assert_eq!(s.backend, NAME); + assert_eq!(s.mode, WakeMode::Block); + assert!(!s.display); + assert_eq!(s.targets, vec![WakeTarget::Idle]); + // idle-inhibit-unstable-v1 is idle-only — it must not claim to block + // system suspend or lid-close (setup.md section 7). + assert!(!s.targets.contains(&WakeTarget::SystemSleep)); + assert!(!s.targets.contains(&WakeTarget::LidSwitch)); + } + + #[test] + fn no_surface_reason_is_stable() { + // acquire() always returns BackendUnavailable with this exact reason; + // pin it so the user-facing error stays consistent. + assert_eq!( + NO_SURFACE_REASON, + "wayland idle-inhibit requires a visible wl_surface" + ); + } + + #[test] + fn acquire_without_surface_is_backend_unavailable() { + // On any host (with or without a compositor) a CLI daemon has no + // wl_surface, so acquire() must always fail fast with BackendUnavailable. + // No compositor is required: the probe inside acquire() is best-effort + // and the error path is unconditional. + let req = crate::model::WakeRequest::default_linux(); + match WaylandBackend::new().acquire(&req) { + Err(OxiwakeError::BackendUnavailable { backend, reason }) => { + assert_eq!(backend, NAME); + assert!( + reason.contains("wl_surface"), + "reason must explain the surface requirement, got: {reason}" + ); + } + Err(other) => panic!("expected BackendUnavailable, got {other:?}"), + Ok(_) => panic!("acquire must not succeed without a wl_surface"), + } + } +} diff --git a/src/backend/windows.rs b/src/backend/windows.rs index 976d4cd..868e4d7 100644 --- a/src/backend/windows.rs +++ b/src/backend/windows.rs @@ -469,8 +469,39 @@ mod tests { assert_eq!(w, vec![b'h' as u16, b'i' as u16, 0]); } + #[test] + fn wide_utf16_z_empty_is_just_nul() { + // An empty reason must still produce a valid NUL-terminated wide string + // (a lone NUL), because PowerCreateRequest always dereferences the + // pointer. An empty vec would be a null-pointer dereference. + assert_eq!(wide_utf16_z(""), vec![0u16]); + } + + #[test] + fn wide_utf16_z_round_trips_through_utf16() { + // Non-ASCII (BMP + astral) must encode as UTF-16 and survive a round + // trip, since the reason string is user-controlled. + let s = "café — 🦀"; + let w = wide_utf16_z(s); + assert_eq!(*w.last().unwrap(), 0); + // Strip the trailing NUL and decode back. + let trimmed = &w[..w.len() - 1]; + let decoded = String::from_utf16_lossy(trimmed); + assert_eq!(decoded, s); + } + #[test] fn backend_name_is_stable() { assert_eq!(BACKEND_NAME, "win32-power"); } + + #[test] + fn name_and_supported_with_default() { + // `new()` and `default()` are no-I/O handles; the name string is a + // stable contract surfaced via `ow doctor` / state.json. + let b = Win32PowerBackend::new(); + assert_eq!(b.name(), BACKEND_NAME); + assert!(b.supported()); + assert_eq!(Win32PowerBackend::default().name(), BACKEND_NAME); + } } diff --git a/src/backend/x11.rs b/src/backend/x11.rs index 317c07b..88f4543 100644 --- a/src/backend/x11.rs +++ b/src/backend/x11.rs @@ -389,4 +389,25 @@ mod tests { Ok(()) => panic!("absent extension must be an error"), } } + + #[test] + fn doctor_returns_ok_with_fixed_name_and_guarantees() { + // doctor() opens the X display; on a host with no DISPLAY it still + // returns Ok with available=false. The name/guarantees are deterministic + // and pinned here; availability legitimately varies, so we do not + // assert it. + let report = X11Backend::new().doctor().expect("doctor must be Ok"); + assert_eq!(report.backend, NAME); + assert!(report.supported); + let joined = report.guarantees.join(" | "); + // setup.md section 6 caveats: display/idle-level only, NOT system-level. + assert!( + joined.contains("display/idle") || joined.contains("screensaver"), + "guarantees must disclose display/idle-level scope, got: {joined}" + ); + assert!( + joined.contains("system suspend") || joined.contains("system-level"), + "guarantees must disclaim system-level blocking, got: {joined}" + ); + } } diff --git a/src/error.rs b/src/error.rs index df01031..dc40012 100644 --- a/src/error.rs +++ b/src/error.rs @@ -55,6 +55,14 @@ pub enum OxiwakeError { NotRunning, /// `ow on` was asked but a daemon is already running. + /// + /// *Reserved for future use.* No v0.1 code path constructs this variant: + /// `ensure_started` detects an already-running daemon by successfully + /// pinging it and treats that as a no-op success rather than an error. + /// It is kept on the enum so the `Display` string + /// (`"oxiwake is already running (pid {0})"`) is stable before the first + /// publish — the JSON output contract and human messages depend on the + /// `thiserror` strings in this file staying constant. #[error("oxiwake is already running (pid {0})")] AlreadyRunning(u32), diff --git a/src/model.rs b/src/model.rs index 08dadcb..bbbdd5c 100644 --- a/src/model.rs +++ b/src/model.rs @@ -54,8 +54,16 @@ pub enum WakeMode { #[default] Block, /// Take the delay lock: the action proceeds after a bounded delay even if we hold the lock. + /// + /// *Reserved for future use.* No v0.1 backend selects this mode; it is kept + /// on the enum so the serialized contract (`"delay"`) is stable before the + /// first publish. Backends gain it without a breaking JSON change. Delay, /// Block-weak: a weaker form of `Block` (overridden by `block` inhibitors). + /// + /// *Reserved for future use.* No v0.1 backend selects this mode; it is kept + /// on the enum so the serialized contract (`"block-weak"`) is stable before + /// the first publish. BlockWeak, } @@ -163,6 +171,12 @@ pub struct DoctorReport { impl DoctorReport { /// A blank report for a backend that is not compiled in at all. + /// + /// *Reserved / utility constructor.* No v0.1 doctor code path currently + /// calls this (every compiled-in backend reports itself via its own + /// `doctor()` impl), but it is kept as a documented, tested helper so a + /// future build that compiles in only a subset of backends can describe the + /// absent ones uniformly. `tests/model.rs` pins its shape. pub fn not_compiled(name: &'static str) -> Self { DoctorReport { backend: name.to_string(), diff --git a/src/paths.rs b/src/paths.rs index d3d0999..cb160df 100644 --- a/src/paths.rs +++ b/src/paths.rs @@ -18,7 +18,7 @@ //! [`Paths::resolve`] returns an [`OxiwakeError::State`] error. (A future //! version should prefer `SHGetKnownFolderPath(FOLDERID_LocalAppData)`.) -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use crate::error::{OxiwakeError, Result}; @@ -51,12 +51,34 @@ impl Paths { /// looser permissions is tightened best-effort. Returns /// [`OxiwakeError::State`] if the base runtime directory cannot be /// determined for this platform. + /// + /// This is the thin platform-aware wrapper: it reads the environment to + /// discover the base directory (via [`Self::runtime_dir_or_none`]) and then + /// delegates the pure filesystem work to [`Self::resolve_under`]. Splitting + /// the two keeps the env-dependent half tiny and leaves the directory-creation, + /// permission-tightening, and path-building logic independently testable + /// (see `tests::resolve_under_*`) without mutating process-global env vars. pub fn resolve() -> Result { let dir = Self::runtime_dir_or_none() .ok_or_else(|| OxiwakeError::State(base_dir_unset_message().to_string()))?; + Self::resolve_under(&dir) + } + /// Create `base` (and parents) if missing, tighten it to `0700` on Linux, + /// and build the [`Paths`] for the well-known files inside it. + /// + /// This is the pure, env-free half of [`Paths::resolve`]: given an already- + /// resolved base directory it produces exactly the struct `resolve` would + /// have returned. Factoring it out makes the directory setup unit-testable + /// without touching process-global environment variables (which would race + /// with parallel tests). + /// + /// On Linux the directory is forced to mode `0700`; an existing directory + /// with looser permissions is tightened best-effort (a chmod failure does + /// not mask a successful resolution). + fn resolve_under(base: &Path) -> Result { // Create the directory (and any missing parents). - std::fs::create_dir_all(&dir)?; + std::fs::create_dir_all(base)?; // On Linux, ensure the directory is private to the owning user. The // XDG_RUNTIME_DIR base is conventionally 0700, but be defensive: if a @@ -69,25 +91,25 @@ impl Paths { const PRIVATE_MODE: u32 = 0o700; // create_dir_all just succeeded, so a metadata failure here is // exotic; ignore it rather than blocking startup. - if let Ok(meta) = std::fs::metadata(&dir) { + if let Ok(meta) = std::fs::metadata(base) { let cur = meta.permissions().mode(); if cur & 0o777 != PRIVATE_MODE { let _ = std::fs::set_permissions( - &dir, + base, std::fs::Permissions::from_mode(PRIVATE_MODE), ); } } } - let state = dir.join("state.json"); + let state = base.join("state.json"); // The IPC socket name is platform-neutral; on Linux this is a Unix // domain socket. - let socket = dir.join("oxiwake.sock"); - let lock = dir.join("oxiwake.lock"); + let socket = base.join("oxiwake.sock"); + let lock = base.join("oxiwake.lock"); Ok(Paths { - dir, + dir: base.to_path_buf(), state, socket, lock, @@ -146,3 +168,123 @@ fn base_dir_unset_message() -> &'static str { "no runtime directory is configured for this platform" } } + +#[cfg(test)] +mod tests { + use super::*; + + use std::sync::atomic::{AtomicU64, Ordering}; + + // A per-test-call nonce so parallel tests don't collide on the same dir. + static NONCE: AtomicU64 = AtomicU64::new(0); + fn unique_nonce() -> u64 { + NONCE.fetch_add(1, Ordering::Relaxed) + } + + /// Build a unique, scratch directory under the system temp dir. + /// + /// Each test gets its own path (process id + per-call nonce) so parallel + /// test threads never touch the same directory. The directory is removed on + /// test exit (best-effort) via [`Guard`]. + fn scratch_dir() -> PathBuf { + let mut p = std::env::temp_dir(); + p.push(format!( + "oxiwake-paths-test-{}-{}", + std::process::id(), + unique_nonce() + )); + p + } + + /// RAII guard that removes the scratch dir when dropped, so leaked temp + /// state never accumulates across test runs. + struct Guard(PathBuf); + impl Drop for Guard { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + /// `resolve_under` must create the `oxiwake`-style directory itself (it does + /// not assume the caller pre-created it). + #[test] + fn resolve_under_creates_missing_dir() { + let base = scratch_dir(); + let _g = Guard(base.clone()); + assert!(!base.exists(), "precondition: base must not exist yet"); + + let paths = Paths::resolve_under(&base).expect("resolve_under"); + assert!(base.is_dir(), "base directory must be created"); + assert_eq!(paths.dir, base, "dir field must equal the base"); + } + + /// All four well-known paths must hang off `base` with the agreed names. + #[test] + fn resolve_under_paths_are_correct() { + let base = scratch_dir(); + let _g = Guard(base.clone()); + + let paths = Paths::resolve_under(&base).expect("resolve_under"); + assert_eq!(paths.dir, base); + assert_eq!(paths.state, base.join("state.json")); + assert_eq!(paths.socket, base.join("oxiwake.sock")); + assert_eq!(paths.lock, base.join("oxiwake.lock")); + } + + /// Calling `resolve_under` twice on the same directory must not error — + /// `create_dir_all` is idempotent, and so is the chmod. + #[test] + fn resolve_under_is_idempotent() { + let base = scratch_dir(); + let _g = Guard(base.clone()); + + let _first = Paths::resolve_under(&base).expect("first resolve_under"); + // Second call over the now-existing directory must succeed and report + // the same paths. + let second = Paths::resolve_under(&base).expect("second resolve_under"); + assert_eq!(second.dir, base); + assert_eq!(second.state, base.join("state.json")); + } + + /// `resolve_under` must also create missing *parent* directories, since it + /// is documented to wrap `create_dir_all`. We guard the scratch *root* (the + /// unique top-level dir) so the whole nested tree is removed on drop. + #[test] + fn resolve_under_creates_missing_parents() { + let root = scratch_dir(); + let _g = Guard(root.clone()); + let base = root.join("nested/deeper"); + + let paths = Paths::resolve_under(&base).expect("resolve_under with parents"); + assert!(base.is_dir()); + assert_eq!(paths.dir, base); + } + + /// On Linux the runtime directory must end up at mode `0700` whether it was + /// just created or pre-existed with looser permissions. Skipped off Linux. + #[cfg(target_os = "linux")] + #[test] + fn resolve_under_tightens_to_0700() { + use std::os::unix::fs::PermissionsExt; + + let base = scratch_dir(); + let _g = Guard(base.clone()); + + // Fresh creation -> 0700. + let _ = Paths::resolve_under(&base).expect("first resolve_under"); + let mode = std::fs::metadata(&base) + .expect("metadata") + .permissions() + .mode(); + assert_eq!(mode & 0o777, 0o700, "freshly created dir must be 0700"); + + // Loosen to 0777, then re-resolve: it must be tightened back to 0700. + std::fs::set_permissions(&base, std::fs::Permissions::from_mode(0o777)).expect("loosen"); + let _ = Paths::resolve_under(&base).expect("second resolve_under"); + let mode = std::fs::metadata(&base) + .expect("metadata after tighten") + .permissions() + .mode(); + assert_eq!(mode & 0o777, 0o700, "loose dir must be tightened to 0700"); + } +} diff --git a/tests/cli_smoke.rs b/tests/cli_smoke.rs new file mode 100644 index 0000000..3bae56f --- /dev/null +++ b/tests/cli_smoke.rs @@ -0,0 +1,288 @@ +//! End-to-end CLI smoke tests for the `ow` binary. +//! +//! These spawn the real compiled `ow` binary (located via the +//! `CARGO_BIN_EXE_ow` environment variable cargo sets for bin crates) and drive +//! its user-facing verbs: `--help`, `--version`, `status` (human + JSON), and +//! `doctor`. Each test points `XDG_RUNTIME_DIR` at its own freshly-created temp +//! directory via [`Command::env`], which scopes the variable to the child +//! process only — the real environment is never mutated, and parallel test +//! threads never collide because every directory name is unique. +//! +//! Scope is deliberately narrow: these tests assert the CLI *behaves* (right +//! exit codes, right wording for the not-running case, valid JSON where +//! claimed). They do not exercise the live daemon, which needs a working Polkit +//! / D-Bus session — that path is covered by `tests/daemon_lifecycle.rs` with a +//! mock backend instead. + +use std::env; +use std::fs; +use std::path::PathBuf; +use std::process::Command; +use std::sync::atomic::{AtomicU64, Ordering}; + +use serde_json::Value; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Path to the compiled `ow` binary. +/// +/// Cargo sets `CARGO_BIN_EXE_ow` for bin crates when running integration +/// tests; it is the canonical, build-backend-agnostic way to locate the +/// artifact under test. +fn ow() -> PathBuf { + env::var_os("CARGO_BIN_EXE_ow") + .map(PathBuf::from) + .expect("CARGO_BIN_EXE_ow must be set by the test harness") +} + +/// A monotonically increasing counter so every call to [`scratch_dir`] gets a +/// distinct path even when tests run in parallel. +static NONCE: AtomicU64 = AtomicU64::new(0); + +fn unique_nonce() -> u64 { + NONCE.fetch_add(1, Ordering::Relaxed) +} + +/// Build a unique scratch directory under the system temp dir. +/// +/// Each call yields `/ow-smoke--` so parallel test threads +/// never touch the same directory. The directory itself is not created here — +/// [`ScratchDir::new`] creates (and later removes) it together with the +/// `oxiwake` subdirectory `Paths::resolve` will append on Linux. +fn scratch_name() -> String { + format!("ow-smoke-{}-{}", std::process::id(), unique_nonce()) +} + +/// RAII guard around a fresh `XDG_RUNTIME_DIR`. +/// +/// Creates the directory on construction (so the child can resolve paths under +/// it) and removes the whole tree on drop, so leaked temp state never +/// accumulates across test runs. The directory's path is the value to hand to +/// `Command::env("XDG_RUNTIME_DIR", ...)`. +struct ScratchDir { + path: PathBuf, +} + +impl ScratchDir { + fn new() -> ScratchDir { + let mut path = env::temp_dir(); + path.push(scratch_name()); + // `Paths::resolve` will create `/oxiwake` itself, but the base + // XDG_RUNTIME_DIR must already exist (and be a directory) for the + // child to treat it as a valid runtime root. + fs::create_dir_all(&path) + .unwrap_or_else(|e| panic!("could not create scratch dir {path:?}: {e}")); + ScratchDir { path } + } + + fn path(&self) -> &std::path::Path { + &self.path + } +} + +impl Drop for ScratchDir { + fn drop(&mut self) { + // Best-effort: a failure to clean up must not fail the test. + let _ = fs::remove_dir_all(&self.path); + } +} + +/// Run `ow` with the given args and `XDG_RUNTIME_DIR` scoped to a fresh temp +/// dir, returning the completed child process. +fn run_ow(args: &[&str], xdg: Option<&std::path::Path>) -> std::process::Output { + let mut cmd = Command::new(ow()); + cmd.args(args); + if let Some(dir) = xdg { + // Scope the env var to the child only — the parent process environment + // (and thus other parallel tests) is untouched. + cmd.env("XDG_RUNTIME_DIR", dir); + } + cmd.output() + .unwrap_or_else(|e| panic!("failed to spawn `ow {args:?}`: {e}")) +} + +/// Run `ow` without scoping `XDG_RUNTIME_DIR` (for commands like `--help` and +/// `doctor` that do not need an isolated runtime dir). +fn run_ow_plain(args: &[&str]) -> std::process::Output { + run_ow(args, None) +} + +/// Decode a command's stdout as UTF-8, panicking with context on failure. +fn stdout(output: &std::process::Output) -> String { + String::from_utf8(output.stdout.clone()) + .unwrap_or_else(|e| panic!("ow stdout was not valid UTF-8: {e}")) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +/// `ow --help` exits 0 and lists the user-facing verbs. +#[test] +fn help_long() { + let out = run_ow_plain(&["--help"]); + assert!( + out.status.success(), + "`ow --help` should exit 0 (got {:?})", + out.status.code() + ); + let s = stdout(&out); + for verb in ["on", "off", "toggle", "status", "doctor"] { + assert!( + s.contains(verb), + "`ow --help` output should mention the `{verb}` subcommand" + ); + } +} + +/// `ow -h` (short form) behaves the same as `--help`. +#[test] +fn help_short() { + let out = run_ow_plain(&["-h"]); + assert!( + out.status.success(), + "`ow -h` should exit 0 (got {:?})", + out.status.code() + ); + let s = stdout(&out); + assert!( + s.contains("status"), + "`ow -h` output should mention `status`" + ); + assert!( + s.contains("doctor"), + "`ow -h` output should mention `doctor`" + ); +} + +/// `ow --version` exits 0. clap renders this as ` `, so we +/// assert the binary name and a version-shaped token rather than the about +/// string (which only appears in `--help`). +#[test] +fn version() { + let out = run_ow_plain(&["--version"]); + assert!( + out.status.success(), + "`ow --version` should exit 0 (got {:?})", + out.status.code() + ); + let s = stdout(&out); + // clap's default version line is `ow `; the binary name is always + // present, and a version always contains at least one digit. + assert!( + s.contains("ow"), + "`ow --version` output should name the binary: {s:?}" + ); + assert!( + s.chars().any(|c| c.is_ascii_digit()), + "`ow --version` output should contain a version number: {s:?}" + ); +} + +/// `ow status` with no daemon running and a fresh XDG dir exits 0 and reports +/// "off". The exact wording (`human_off`) is `"state: off (oxiwake is not +/// running)"`, so we assert the substring "off" which is both stable and +/// meaningful. +#[test] +fn status_off_human() { + let dir = ScratchDir::new(); + let out = run_ow(&["status"], Some(dir.path())); + assert!( + out.status.success(), + "`ow status` (no daemon) should exit 0, got {:?}; stderr: {}", + out.status.code(), + String::from_utf8_lossy(&out.stderr) + ); + let s = stdout(&out); + assert!( + s.contains("off"), + "`ow status` with no daemon should indicate off, got: {s:?}" + ); +} + +/// `ow --json status` with no daemon running prints valid JSON reflecting the +/// not-running / off state. +#[test] +fn status_off_json() { + let dir = ScratchDir::new(); + let out = run_ow(&["--json", "status"], Some(dir.path())); + assert!( + out.status.success(), + "`ow --json status` (no daemon) should exit 0, got {:?}; stderr: {}", + out.status.code(), + String::from_utf8_lossy(&out.stderr) + ); + let s = stdout(&out); + let v: Value = serde_json::from_str(s.trim()).unwrap_or_else(|e| { + panic!("`ow --json status` stdout should be valid JSON, got {s:?}: {e}") + }); + // `json_off()` emits `{"state":"off","running":false}`. + assert_eq!( + v["state"], "off", + "JSON status with no daemon should be state=off, got: {v}" + ); + assert_eq!( + v["running"], false, + "JSON status with no daemon should be running=false, got: {v}" + ); +} + +/// `ow doctor` exits 0 and emits at least one backend row. `run_doctor` is +/// infallible, so the exit code is always 0; the human report always carries a +/// "backend" column header and one row per compiled-in backend (the default +/// feature set compiles several). +#[test] +fn doctor_human() { + let out = run_ow_plain(&["doctor"]); + assert!( + out.status.success(), + "`ow doctor` should exit 0, got {:?}; stderr: {}", + out.status.code(), + String::from_utf8_lossy(&out.stderr) + ); + let s = stdout(&out); + assert!( + s.contains("backend"), + "`ow doctor` output should have a backend column, got: {s:?}" + ); + // The header line is followed by a separator and at least one backend row. + // A backend name row appears after the "backend" header; assert there is + // more than just the header by checking for a second non-empty line past it. + assert!( + s.lines().filter(|l| !l.trim().is_empty()).count() > 1, + "`ow doctor` should emit at least one backend row, got: {s:?}" + ); +} + +/// `ow --json doctor` emits valid JSON carrying a non-empty `backends` array. +#[test] +fn doctor_json() { + let out = run_ow_plain(&["--json", "doctor"]); + assert!( + out.status.success(), + "`ow --json doctor` should exit 0, got {:?}; stderr: {}", + out.status.code(), + String::from_utf8_lossy(&out.stderr) + ); + let s = stdout(&out); + let v: Value = serde_json::from_str(s.trim()).unwrap_or_else(|e| { + panic!("`ow --json doctor` stdout should be valid JSON, got {s:?}: {e}") + }); + // `DoctorOutput` serializes as { platform, env, backends }. + let backends = v + .get("backends") + .and_then(|b| b.as_array()) + .unwrap_or_else(|| panic!("`ow --json doctor` should have a backends array, got: {v}")); + assert!( + !backends.is_empty(), + "`ow --json doctor` should report at least one backend, got: {v}" + ); + // The platform string must be truthful about the build target. + let platform = v["platform"].as_str().unwrap_or(""); + assert!( + platform == "linux" || platform == "windows" || platform == "unknown", + "platform should be a known family, got: {platform:?}" + ); +}