feat(core): add workspace skeleton and the pure decision engine

Establishes the cargo workspace and the shared `blocker-core` crate. The
engine is deliberately written before any platform code, because CLAUDE.md
section 2 makes it the single source of decision truth and M1 acceptance
requires it to be pure.

**Workspace** — five crates plus a test crate, zero external dependencies.
Section 3 requires every crate to justify its binary-size cost, so M0 adds
none. [profile.release] is workspace-only (cargo rejects it in members) and
carries the section 5 settings that make the 5 MB stripped budget reachable.

**Purity contract** — `evaluate(&EngineInput) -> Decision` reads no clock, no
disk and no OS API; everything arrives in the input. `tests/purity.rs`
enforces this mechanically by asserting blocker-core has zero dependencies, so
a stray I/O reach-in fails the build rather than silently making the golden
tables useless as a parity contract.

Decisions worth calling out:

- **Monotonic time is a separate type from wall time.** Section 1.5.3 requires
  a system date change never to shorten a lock. Keeping them apart makes that
  structural rather than a rule to remember.
- **`start > end` means overnight.** No `crosses_midnight` flag: the comparison
  already encodes it and a second field is a second way to be wrong. The
  weekday for a window that started yesterday evening is yesterday's, which
  `overnight_window_survives_past_midnight` pins down.
- **`next_wakeup` is armed over every non-paused schedule, not only active
  ones.** Filtering to active conditions first would let the daemon sleep
  straight through the moment a schedule is due to start.
- **Allowlist needs the installed-app inventory.** `EngineInput` carries
  `installed_apps`; an empty slice fails open rather than guessing.
- **Wall-clock correction extends, never shortens, and clamps to the 24h cap.**
  Extending unbounded would satisfy anti-tamper and violate section 1.5.4.
- **`cast_possible_truncation` is denied workspace-wide.** Truncating a
  millisecond counter would corrupt a lock timer.

Verified: 22 tests pass, `cargo fmt --check` clean, `cargo clippy -D warnings`
clean, stripped release daemon 0.279 MB against the 5 MB budget.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
2026-10-01 15:02:20 +03:00
parent aa1c582f3c
commit c723efb186
25 changed files with 2794 additions and 31 deletions
+115
View File
@@ -0,0 +1,115 @@
//! Linux platform layer.
//!
//! Observes the OS and enforces decisions. Contains **no decision logic**
//! (CLAUDE.md section 2).
//!
//! # The trait this crate exists to implement
//!
//! Linux has no portable way to ask "which window has focus" under Wayland.
//! The [`ForegroundWatcher`] trait below is the seam, and it will have three
//! implementations, tried in order at startup:
//!
//! 1. **`KwinBackend`** — the primary path on this machine. Loads a small
//! KWin script over the `org.kde.KWin /Scripting` D-Bus interface, which
//! hooks `windowActivated` and reports back to the daemon. Verified
//! available on KDE Plasma 6.6.6: `loadScript`, `start`, `unloadScript`,
//! `isScriptLoaded` are all present.
//!
//! 2. **`X11Backend`** — the portable fallback. `x11rb` subscribes to
//! `_NET_ACTIVE_WINDOW` property changes, resolves `_NET_WM_PID`, then reads
//! `/proc/<pid>/exe`. Works on any X11 session and is testable headless with
//! Xvfb, so this is also the path CI exercises.
//!
//! 3. **`ProcessBackend`** — a degraded last resort with no idea what has focus,
//! which acts on process creation instead. It cannot show a meaningful block
//! screen and should never be reached on a working KDE session.
//!
//! # Honest limits (CLAUDE.md section 6)
//!
//! - `X11Backend` is **blind to native Wayland apps** under a Wayland session.
//! Under XWayland it sees only XWayland clients. This is why it is a fallback
//! and not the primary.
//! - `/proc/connectors` does not exist on the development kernel, so the
//! netlink proc-connector process watcher is unavailable and
//! `ProcessBackend` must use eBPF or `/proc` polling — a worse experience
//! than a block screen, by construction.
//! - The KWin backend is KDE-specific. wlroots
//! (`foreign-toplevel-management`) and GNOME backends remain in M7 per
//! section 8, because no portable API exists to build them against.
//! - None of this can be made tamper-proof against a user with root. Section
//! 6.1 requires this be documented honestly rather than implied otherwise.
/// A window that has taken focus, resolved to something a schedule can match.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ForegroundWindow {
/// Backend-native window handle. KWin gives a `uuid` string; X11 gives an
/// XID. Kept as a string so the trait is not tied to one backend.
pub handle: String,
/// Resolved process id, where the backend can supply one.
pub pid: Option<u32>,
/// Absolute path of the executable, read from `/proc/<pid>/exe`.
pub exe_path: Option<String>,
/// Window title, for display on the block screen.
pub title: Option<String>,
/// Desktop-file id or WM_CLASS, which is what a Linux user actually
/// recognises as "the app" and what schedules should match against.
pub desktop_id: Option<String>,
}
/// Events a watcher can raise. The daemon blocks on this channel; there is no
/// polling (section 2).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FocusEvent {
/// A window gained focus.
Activated(ForegroundWindow),
/// The focused window closed, so there is no longer a foreground window.
Cleared,
}
/// Source of focus events.
///
/// Implemented once per backend. M2-Linux fills these in.
pub trait ForegroundWatcher {
/// Begin delivering events to `sink`.
///
/// Implementations must be event-driven: they subscribe to an X11 property
/// change or a compositor callback, never to a timer. An implementation
/// that polls would break the section 5 idle budget even if it functioned.
fn subscribe(&mut self, sink: std::sync::mpsc::Sender<FocusEvent>);
/// Which backend this is, for the troubleshooting screen and the log.
fn name(&self) -> &'static str;
/// Why this backend was chosen, in plain language for the user.
fn selection_reason(&self) -> &'static str;
}
/// Enforcement actions, in escalating order and configurable by the user
/// (CLAUDE.md section 4.3).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Enforcement {
/// Show the block screen. The default, and the only non-destructive option.
BlockScreen,
/// Ask the compositor to minimise the window.
Minimise,
/// `SIGSTOP` the process. Freezes the app rather than closing it, so the
/// user's state is preserved and the resume is honest.
Freeze,
/// Terminate the process. Destructive, and the last resort.
Terminate,
}
/// What the platform layer may and may not do unprivileged.
///
/// Recorded here because section 0 rule 2 requires honesty rather than a stub
/// that pretends. Each of these is a real limit of an unprivileged user
/// daemon, not a missing feature.
pub const UNPRIVILEGED_LIMITS: &[&str] = &[
"cannot prevent a process from launching, only react to it after the fact",
"cannot mask a systemd unit (that needs root); tamper resistance is \
Restart=always plus a watchdog, nothing stronger",
"cannot make its own config tamper-proof against the same user, who owns the \
files — lock state lives in the DB instead (section 1.5.3)",
"cannot read /proc/<pid>/exe for processes owned by other users",
"cannot block a process that is already running as root",
];