From c723efb186e1dc343cff74e8ee8b791bfd0512aa Mon Sep 17 00:00:00 2001 From: SellPL Date: Thu, 1 Oct 2026 15:02:20 +0300 Subject: [PATCH] feat(core): add workspace skeleton and the pure decision engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Cargo.lock | 39 ++ Cargo.toml | 65 +++ clippy.toml | 48 +- core/Cargo.toml | 19 + core/src/engine.rs | 836 ++++++++++++++++++++++++++++ core/src/lib.rs | 63 +++ core/src/lock.rs | 276 +++++++++ core/src/model.rs | 264 +++++++++ core/src/never_block.rs | 65 +++ core/src/storage.rs | 92 +++ core/src/usage.rs | 131 +++++ desktop/daemon/Cargo.toml | 19 + desktop/daemon/src/main.rs | 33 ++ desktop/platform-linux/Cargo.toml | 16 + desktop/platform-linux/src/lib.rs | 115 ++++ desktop/platform-windows/Cargo.toml | 20 + desktop/platform-windows/src/lib.rs | 64 +++ desktop/ui/Cargo.toml | 27 + desktop/ui/src/main.rs | 27 + rust-toolchain.toml | 7 + rustfmt.toml | 23 +- tests/Cargo.toml | 36 ++ tests/src/golden.rs | 242 ++++++++ tests/src/lib.rs | 37 ++ tests/src/purity.rs | 261 +++++++++ 25 files changed, 2794 insertions(+), 31 deletions(-) create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 core/Cargo.toml create mode 100644 core/src/engine.rs create mode 100644 core/src/lib.rs create mode 100644 core/src/lock.rs create mode 100644 core/src/model.rs create mode 100644 core/src/never_block.rs create mode 100644 core/src/storage.rs create mode 100644 core/src/usage.rs create mode 100644 desktop/daemon/Cargo.toml create mode 100644 desktop/daemon/src/main.rs create mode 100644 desktop/platform-linux/Cargo.toml create mode 100644 desktop/platform-linux/src/lib.rs create mode 100644 desktop/platform-windows/Cargo.toml create mode 100644 desktop/platform-windows/src/lib.rs create mode 100644 desktop/ui/Cargo.toml create mode 100644 desktop/ui/src/main.rs create mode 100644 rust-toolchain.toml create mode 100644 tests/Cargo.toml create mode 100644 tests/src/golden.rs create mode 100644 tests/src/lib.rs create mode 100644 tests/src/purity.rs diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..d302ddb --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,39 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "blocker-core" +version = "0.1.0" + +[[package]] +name = "blocker-daemon" +version = "0.1.0" +dependencies = [ + "blocker-core", +] + +[[package]] +name = "blocker-platform-linux" +version = "0.1.0" +dependencies = [ + "blocker-core", +] + +[[package]] +name = "blocker-platform-windows" +version = "0.1.0" +dependencies = [ + "blocker-core", +] + +[[package]] +name = "blocker-tests" +version = "0.1.0" +dependencies = [ + "blocker-core", +] + +[[package]] +name = "blocker-ui" +version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..5ca4810 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,65 @@ +[workspace] +resolver = "2" +members = [ + "core", + "tests", + "desktop/daemon", + "desktop/ui", + "desktop/platform-linux", + "desktop/platform-windows", +] + +# Release profile is workspace-wide (section 5 of CLAUDE.md). Cargo rejects +# [profile.*] in member manifests, so this lives here and nowhere else. +# +# The combination is deliberate: `z` optimises for size, LTO + codegen-units=1 +# let the linker drop everything unreachable, and panic="abort" removes +# unwinding tables entirely. Together they are what make the 5 MB stripped +# daemon budget (section 5) reachable. scripts/ci.sh enforces the size gate. +# Workspace-wide lints. These apply to every member crate. +# +# They are set here rather than in clippy.toml because they must apply to the +# compilation — clippy.toml only accepts bare key=value pairs, and a `[lints]` +# table there is rejected outright. crates.io-wide intent is the same: these +# encode CLAUDE.md section 0 rule 5 (every fallible OS call handled, no +# unwrap/expect outside tests) and the section 1.5.3 arithmetic care. +[workspace.lints.rust] +# Rustc lints only. NOTE: `integer_conversions` is NOT a rustc lint name and +# cargo rejects it here as an unknown lint, so the u64 -> u32 truncation concern +# is expressed as the clippy lint below, which is the check that actually exists. +unsafe_code = "forbid" + +[workspace.lints.clippy] +# Panicking in a release build leaves enforcement down. Section 1.5.4 requires +# failing open with a log line, not aborting. +panic = "deny" +# `unwrap`/`expect` on an OS or I/O result is forbidden outside tests (section 0 +# rule 5). +unwrap_used = "deny" +expect_used = "deny" +# Silent u64 -> u32 truncation in usage counters and monotonic deltas is a real +# bug class in this codebase, not a style issue: an overflow there would corrupt +# a lock timer, which section 1.5.3 depends on. +cast_possible_truncation = "deny" +# Stack waste in the evaluator, which runs on every focus change. +large_enum_variant = "warn" +# Clarity over cleverness; section 5 budgets are measured, not guessed. +too_many_arguments = "warn" + +[profile.release] +opt-level = "z" +lto = true +codegen-units = 1 +panic = "abort" +strip = true + +# Debug builds keep debug assertions and overflow checks — the engine does +# arithmetic on millisecond counters and monotonic deltas, and overflow in a +# release build would corrupt a lock timer (section 1.5.3). +[profile.dev] +opt-level = 0 +debug = true + +# Tests are compiled without panic=abort so that should_panic works. +[profile.test] +opt-level = 1 \ No newline at end of file diff --git a/clippy.toml b/clippy.toml index 5225afc..6e79c76 100644 --- a/clippy.toml +++ b/clippy.toml @@ -1,26 +1,24 @@ -# Clippy is run with `-D warnings` in CI (CLAUDE.md §10.4), so every lint we -# enable here is a build failure. Only turn on lints we are willing to fix. +# Clippy configuration for Blocker. +# +# WHY THIS FILE IS EMPTY OF LINTS +# +# Clippy rejects unknown keys in this file outright, and several plausible lint +# spellings are not accepted here: +# +# - Lint names must be written exactly as clippy knows them. `large-enum-variant` +# with dashes is NOT accepted as a bare key in this file, even though the +# dash form is valid in a `[lints.clippy]` Cargo.toml table. +# - `integer_conversions` is not a rustc lint name at all; the real check for +# silent u64 -> u32 truncation is `clippy::cast_possible_truncation`. +# +# So all lint policy lives in `[workspace.lints]` in Cargo.toml, which is the form +# clippy actually applies during compilation. Keeping it in one place means there +# is exactly one answer to "which lints does this project enforce". +# +# This file is kept rather than deleted because it is the conventional place to +# put clippy behavioural configuration should any non-lint option become +# necessary (for example `msrv`). -[lints.rust] -# Silent truncation of a u64 millisecond count into a u32 is a real bug class in -# this codebase (usage counters, monotonic deltas). Make it impossible to miss. -integer_conversions = "deny" - -[lints.clippy] -# Engine code is mostly pure data transformation; `&self` everywhere reads better -# than `&mut self` and avoids accidental aliasing bugs across the FFI boundary. -needless_pass_by_ref_mut = "warn" - -# Large enum variants waste stack in the hot evaluate() path. -large_enum_variant = "warn" - -# Every OS call returns Result; we handle them all, but catch `.unwrap()` slips. -unwrap_used = "deny" -expect_used = "deny" - -# Panicking in release builds is a last resort — see CLAUDE.md §0.5. -panic = "deny" - -# Prefer clarity over micro-optimisation; perf budgets in §5 are measured with -# real benchmarks, not by guessing at clever rewrites. -too_many_arguments = "warn" +# Set explicitly so clippy does not infer an MSRV from the toolchain and start +# suggesting APIs newer than the project targets. +msrv = "1.75" \ No newline at end of file diff --git a/core/Cargo.toml b/core/Cargo.toml new file mode 100644 index 0000000..915def8 --- /dev/null +++ b/core/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "blocker-core" +version = "0.1.0" +edition = "2021" +description = "Shared decision engine for Blocker: schedules, conditions, usage accounting and lock state. Pure — no OS calls." +license = "UNLICENSED" + +# No dependencies in M0, and deliberately few in M1. Section 3 says prefer std +# over crates and that every added crate must justify its binary-size cost. +# M1 adds rusqlite (bundled SQLite), serde, serde_json and time; each is +# justified in docs/DECISIONS.md against the 5 MB daemon budget. +[dependencies] + +[lints] +workspace = true + +[lib] +name = "blocker_core" +path = "src/lib.rs" \ No newline at end of file diff --git a/core/src/engine.rs b/core/src/engine.rs new file mode 100644 index 0000000..cf90862 --- /dev/null +++ b/core/src/engine.rs @@ -0,0 +1,836 @@ +//! The evaluator: schedules plus context in, a decision out. +//! +//! The signature is the whole design. [`evaluate`] takes everything it could +//! possibly need and returns everything the platform layer needs to act. It +//! reads no clock, touches no disk, and calls no OS API — see the crate-level +//! purity contract. +//! +//! ## Why `next_wakeup` exists +//! +//! Section 2 requires the engine to be event-driven and never polling. The +//! mechanism is this field: the platform layer sleeps until `next_wakeup` or +//! until an OS event arrives, whichever is first. `None` means no schedule can +//! change on its own, so the platform sleeps indefinitely. +//! +//! The consequence for the section 5 budget is the important part: an idle +//! system takes **zero** wakeups, because nothing is scheduled to expire. A +//! timer that fires to discover that nothing changed is a wakeup, and a project +//! with an explicit zero-wakeup budget cannot afford many. + +use crate::model::{BlockReason, Condition, MinuteOfDay, Schedule, ScheduleMode, Target}; + +/// Wall-clock time as seen by a user: a civil date and time in some timezone. +/// +/// Used for schedule windows, where "09:00 means nine in the morning" is the +/// correct behaviour — the same schedule should stay 09:00 across a DST +/// boundary. Never used for lock arithmetic; that is [`Monotonic`]'s job. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub struct CivilTime { + /// Minutes since 1970-01-01T00:00:00Z, plus the local UTC offset in + /// minutes. Storing an offset rather than a resolved zone keeps the type + /// comparable and serialisable without pulling in a full tz database at + /// this layer. + pub epoch_minutes: i64, + /// Local UTC offset in minutes. Kept alongside the epoch value rather than + /// resolved into a timezone, because a schedule means "09:00 in the user's + /// own time" and must stay 09:00 across a DST change. + pub utc_offset_minutes: i32, +} + +/// Milliseconds elapsed since boot, from a monotonic clock. +/// +/// The value wraps nothing and never goes backwards, which is precisely what +/// makes it safe for locks. Section 1.5.3 requires every lock timer to be +/// monotonic so that changing the system date can never shorten a delay, and +/// keeping this a separate type from [`CivilTime`] makes the distinction hard +/// to lose: `evaluate` receives both, and only the monotonic field participates +/// in lock arithmetic. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub struct Monotonic( + /// Elapsed milliseconds since boot. Saturating arithmetic is used on every + /// operation that could otherwise wrap. + pub u64, +); + +/// Identifies one boot of one machine. +/// +/// Lets the lock subsystem tell "the daemon restarted" from "the machine +/// rebooted" (section 1.5.3), so timer state is resumed conservatively rather +/// than assumed to have continued. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub struct BootId(pub u64); + +/// Platform-supplied context the engine cannot observe for itself. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct PlatformContext { + /// Current Wi-Fi SSID, if the platform can supply one. + pub current_ssid: Option, + /// Current location, if the platform is Android and permission is granted. + pub current_location: Option<(f64, f64)>, + /// Section 1.7: what to do when location is unavailable. + pub location_unavailable_behavior: LocationUnavailable, +} + +/// Section 1.7 setting for the case where a location condition exists but no +/// fix is available. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum LocationUnavailable { + /// Treat an unavailable location as "not at the location", so the schedule + /// does not block. Safe default: failing open means a glitch never blocks + /// the user, which is what section 1.5.4 requires. + #[default] + DoNotBlock, + /// Treat an unavailable location as "at the location" and block. Useful + /// when the point of the schedule is to block *leaving* somewhere. + Block, +} + +/// Accumulated usage for every tracked target. +/// +/// Counts are keyed by the same identifier as [`Target`], so lookup during +/// evaluation is a map hit rather than a scan (section 5 — this is the hot +/// path for usage-limit schedules). +#[derive(Debug, Clone, Default, PartialEq)] +pub struct UsageSnapshot { + /// Foreground minutes per app, in the current daily bucket. + pub daily_minutes: std::collections::BTreeMap, + /// Foreground minutes per app, in the current hourly bucket. + pub hourly_minutes: std::collections::BTreeMap, + /// Launch counts per app in the current daily bucket. + pub daily_launches: std::collections::BTreeMap, + /// Launch counts per app in the current hourly bucket. + pub hourly_launches: std::collections::BTreeMap, +} + +/// Everything `evaluate` may read. +#[derive(Debug, Clone, PartialEq)] +pub struct EngineInput<'a> { + /// Wall clock, for schedule windows only. + pub now: CivilTime, + /// Monotonic time, for lock arithmetic only. + pub monotonic: Monotonic, + /// Which boot this is, for reboot detection. + pub boot_id: BootId, + /// Every configured schedule, including paused ones — `evaluate` decides + /// what to skip. + pub schedules: &'a [Schedule], + /// Accumulated usage for the current buckets. + pub usage: &'a UsageSnapshot, + /// What the platform could observe. + pub context: &'a PlatformContext, + /// Apps that exist on this machine, as the platform layer sees them. + /// + /// Needed only by allowlist schedules, which block the complement of their + /// own list (section 1.1) and so cannot be evaluated without knowing what + /// exists. Blocklist schedules ignore it. An empty slice is not an error: it + /// means "we could not enumerate", and the engine then blocks nothing + /// rather than guessing — the fail-open rule of section 1.5.4. + pub installed_apps: &'a [String], +} + +/// Everything `evaluate` produces. +#[derive(Debug, Clone, PartialEq)] +pub struct Decision { + /// Apps currently blocked. Union over all active schedules — this is where + /// strictest-wins (section 1.1) falls out for free: an app is blocked if + /// at least one schedule wants it blocked. + pub blocked_apps: Vec, + /// Websites and keyword matches currently blocked. + pub blocked_domains: Vec, + /// Lock state for the friction ladder (sections 1.4 and 1.5). + pub lock: crate::lock::LockState, + /// The earliest moment any decision could change, or `None` if nothing is + /// time-dependent right now. + /// + /// This is the anti-polling contract. `None` means the platform layer may + /// sleep until an OS event arrives, with no timer at all. + pub next_wakeup: Option, +} + +/// Evaluate the current state into a decision. +/// +/// Pure: no clock read, no I/O, no OS call. Same inputs always produce the same +/// `Decision`, which is what makes the golden tables in `tests/` a parity +/// contract across platforms. +pub fn evaluate(input: &EngineInput<'_>) -> Decision { + let mut blocked_apps: Vec = Vec::new(); + let mut blocked_domains: Vec = Vec::new(); + let mut next_wakeup: Option = None; + + // Allowlist schedules block everything NOT in their list (section 1.1), so + // they cannot be evaluated target-by-target like a blocklist — they need + // the set of apps that actually exist on the machine. Collected first so the + // complement is computed against every active allowlist at once. Owned, not + // borrowed, so nothing is leaked. + let mut permitted: Vec<&str> = Vec::new(); + for schedule in input.schedules { + if schedule.paused || !matches!(schedule.mode, ScheduleMode::Allowlist) { + continue; + } + if active_conditions(schedule, input).is_empty() { + continue; + } + for target in &schedule.targets { + // Section 1.1: allowlist mode is apps-only. Validation rejects a + // website target in an allowlist, so that arm is unreachable for a + // valid schedule. + if let Target::App(id) = target { + permitted.push(id.as_str()); + } + } + } + let has_allowlist = !permitted.is_empty(); + + // Arm the wakeup BEFORE the activity filter below, and over EVERY + // non-paused schedule rather than only currently-active ones. + // + // This ordering is load-bearing. A schedule that is inactive now but starts + // at 09:00 Monday must still arm a wakeup for 09:00 Monday — if only active + // conditions contributed, the daemon would sleep straight through the moment + // it is supposed to start blocking, and the schedule would silently never + // fire. Computing this before `continue` is what makes "no timer unless a + // decision can change" and "never miss a change" both true. + for schedule in input.schedules { + if schedule.paused { + continue; + } + if let Some(boundary) = next_transition(schedule, input) { + next_wakeup = earlier(next_wakeup, boundary); + } + } + + for schedule in input.schedules { + if schedule.paused { + continue; + } + let active = active_conditions(schedule, input); + if active.is_empty() { + continue; + } + + let ends_at = next_boundary(schedule, input); + + match schedule.mode { + ScheduleMode::Blocklist => { + for target in &schedule.targets { + let reason = BlockReason { + schedule_id: schedule.id.clone(), + schedule_name: schedule.name.clone(), + condition: active[0], + ends_at, + }; + match target { + // Section 1.5.4: the never-block check happens here, + // after evaluation, and nothing can reverse it. + Target::App(id) if !crate::never_block::is_never_blocked(id) => { + blocked_apps.push(reason); + } + Target::Website(_) | Target::Keyword(_) => { + blocked_domains.push(reason); + } + Target::App(_) => {} + } + } + } + ScheduleMode::Allowlist => { + // The complement, over the installed-app inventory the platform + // layer supplied. An empty inventory blocks nothing, which is the + // fail-open outcome (section 1.5.4): if we cannot see what is + // installed, we do not guess. + for id in input.installed_apps { + if !has_allowlist + || permitted.contains(&id.as_str()) + || crate::never_block::is_never_blocked(id) + { + continue; + } + blocked_apps.push(BlockReason { + schedule_id: schedule.id.clone(), + schedule_name: schedule.name.clone(), + condition: active[0], + ends_at, + }); + } + } + } + } + + Decision { + blocked_apps, + blocked_domains, + lock: crate::lock::LockState::evaluate(input), + next_wakeup, + } +} + +/// Which conditions of a schedule currently hold. A schedule is active when at +/// least one does (section 1.2). +fn active_conditions(schedule: &Schedule, input: &EngineInput<'_>) -> Vec<&'static str> { + schedule + .conditions + .iter() + .filter(|c| condition_holds(c, input)) + .map(crate::model::condition_name) + .collect() +} + +fn condition_holds(condition: &Condition, input: &EngineInput<'_>) -> bool { + match condition { + Condition::Time(window) => time_window_contains(window, input.now), + Condition::UsageLimit { limit_minutes, window } => { + let used = usage_for(input, window, |u| &u.daily_minutes); + used >= u64::from(*limit_minutes) + } + Condition::LaunchCount { limit, window } => { + let used = usage_for(input, window, |u| &u.daily_launches); + used >= u64::from(*limit) + } + Condition::WifiSsid(ssid) => input.context.current_ssid.as_deref() == Some(ssid.as_str()), + Condition::Location { latitude, longitude, radius_meters, block_when_outside } => { + match input.context.current_location { + None => matches!( + input.context.location_unavailable_behavior, + LocationUnavailable::Block + ), + Some((lat, lon)) => { + let inside = haversine_meters(*latitude, *longitude, lat, lon) + <= f64::from(*radius_meters); + inside != *block_when_outside + } + } + } + } +} + +fn usage_for<'a>( + input: &'a EngineInput<'a>, + window: &crate::model::UsageWindow, + pick: impl Fn(&'a UsageSnapshot) -> &std::collections::BTreeMap, +) -> u64 { + let map = pick(input.usage); + // A usage condition applies to the schedule's own targets, so the sum is + // over all tracked apps; M1 narrows this to the schedule's target set once + // targets carry usage keys. + let _ = window; + map.values().map(|v| u64::from(*v)).sum() +} + +/// The heart of the overnight problem (section 1.2). +/// +/// `start > end` means the window wraps past midnight. When it does, the +/// window opened on the *previous* day, so membership at 03:00 Tuesday must be +/// decided by Monday's weekday. The naive "is now inside today's window" check +/// gets this wrong, and a nightly 22:00–06:00 schedule that stops blocking at +/// midnight is exactly the bug users notice first. +pub fn time_window_contains(window: &crate::model::TimeWindow, now: CivilTime) -> bool { + let minute_of_day = minute_of_day(now); + let today = weekday(now); + + if window.is_overnight() { + // Early part of an overnight window: it started yesterday, so yesterday's + // weekday governs. + if minute_of_day < window.end { + let yesterday = previous_weekday(today); + return window.days.contains(&yesterday); + } + // Late part: it started today. + return window.days.contains(&today) && minute_of_day >= window.start; + } + + window.days.contains(&today) && minute_of_day >= window.start && minute_of_day < window.end +} + +/// When this schedule's current block lifts, if it has an end. +fn next_boundary(schedule: &Schedule, input: &EngineInput<'_>) -> Option { + schedule + .conditions + .iter() + .filter_map(|c| condition_boundary(c, input)) + .min_by_key(|t| t.epoch_minutes) +} + +/// The next moment a condition could flip. +/// +/// This is what makes `next_wakeup` honest: a schedule contributes a boundary +/// only for conditions that are actually time-dependent. A Wi-Fi schedule has +/// no boundary, because the only thing that can change it is an OS event — the +/// NetworkManager callback — not the passage of time. +fn condition_boundary(condition: &Condition, input: &EngineInput<'_>) -> Option { + match condition { + Condition::Time(window) => time_window_boundary(window, input.now), + // Daily and hourly buckets reset at a known civil time, so a + // usage-limited schedule has a real boundary. Without this, a + // usage-limit block would never lift and the daemon would have to poll. + Condition::UsageLimit { window, .. } | Condition::LaunchCount { window, .. } => { + Some(bucket_reset(input.now, window)) + } + // An SSID or location change is an OS event, not a timer. + Condition::WifiSsid(_) | Condition::Location { .. } => None, + } +} + +fn bucket_reset(now: CivilTime, window: &crate::model::UsageWindow) -> CivilTime { + let minute = minute_of_day(now); + let next = match window { + crate::model::UsageWindow::Hourly => (minute / 60 + 1) * 60, + crate::model::UsageWindow::Daily => 1440, + }; + CivilTime { + epoch_minutes: now.epoch_minutes + i64::from(next - minute), + utc_offset_minutes: now.utc_offset_minutes, + } +} + +/// Next boundary of a time window: either its end today, or its start if the +/// window is not currently active. +fn time_window_boundary(window: &crate::model::TimeWindow, now: CivilTime) -> Option { + let minute = minute_of_day(now); + let today = weekday(now); + + if time_window_contains(window, now) { + // Currently active: the end depends on whether we're in the early or + // late part of an overnight window. + let end_today = if window.is_overnight() && minute < window.end { + // Early part — ends later today. + window.end + } else { + window.end + }; + let minutes_until = i64::from(end_today) - i64::from(minute); + return Some(CivilTime { + epoch_minutes: now.epoch_minutes + minutes_until.max(0), + utc_offset_minutes: now.utc_offset_minutes, + }); + } + + // Not active: the next start is the boundary that matters. + let mut best: Option = None; + for day_offset in 0..=7 { + let candidate_day = (today as i32 + day_offset) % 7; + if !window.days.contains(&(candidate_day as u8)) { + continue; + } + let mut delta = i64::from(window.start) - i64::from(minute); + if window.is_overnight() { + // For an overnight window starting tomorrow evening, we want the + // next *start*, which is what wakes us to start blocking. + delta = i64::from(window.start) - i64::from(minute); + } + if delta < 0 { + delta += 24 * 60; + } + best = Some(best.map_or(delta, |b: i64| b.min(delta))); + if best == Some(0) { + break; + } + } + + best.map(|delta| CivilTime { + epoch_minutes: now.epoch_minutes + delta, + utc_offset_minutes: now.utc_offset_minutes, + }) +} + +/// The next moment this schedule's own state could change. +/// +/// Unlike [`condition_boundary`], which answers "when does a *firing* condition +/// stop firing", this also covers a condition that is currently false — a time +/// window that has not started yet still has a start, and that start is exactly +/// when the daemon must wake up to begin blocking. +/// +/// Returns `None` when nothing about the schedule is time-dependent, which is +/// the case for Wi-Fi and location schedules: only an OS event can change them, +/// so arming a timer would be a wakeup for nothing (section 5). +fn next_transition(schedule: &Schedule, input: &EngineInput<'_>) -> Option { + schedule + .conditions + .iter() + .filter_map(|c| condition_boundary(c, input)) + .min_by_key(|t| t.epoch_minutes) +} + +fn earlier(a: Option, b: CivilTime) -> Option { + Some(match a { + Some(existing) if existing <= b => existing, + _ => b, + }) +} + +/// Minutes since local midnight. The offset is folded in so that DST transitions +/// move this value, which is what makes a fixed local window like 09:00–17:00 +/// stay fixed through a DST change. +fn minute_of_day(now: CivilTime) -> MinuteOfDay { + let minutes = now.epoch_minutes + i64::from(now.utc_offset_minutes); + // `rem_euclid(1440)` is always in 0..1440, so this conversion cannot fail + // and the fallback is unreachable. Written as `try_from` rather than `as` + // because the workspace denies `cast_possible_truncation`, and a silent + // truncation here would shift every schedule boundary by an hour. + u16::try_from(minutes.rem_euclid(24 * 60)).unwrap_or(MinuteOfDay::MAX) +} + +/// Days since 1970-01-01, which was a Thursday. Offset so Monday = 0, matching +/// `time::Weekday` and the day numbering used by the golden tables. +fn weekday(now: CivilTime) -> u8 { + let days = now.epoch_minutes.div_euclid(24 * 60); + // 1970-01-01 was a Thursday = index 3 with Monday = 0. + // `rem_euclid(7)` is always in 0..7; see `minute_of_day` for why this is + // `try_from` and not `as`. + u8::try_from((days + 3).rem_euclid(7)).unwrap_or(0) +} + +fn previous_weekday(day: u8) -> u8 { + (day + 6) % 7 +} + +/// Great-circle distance in metres. +/// +/// `f64::sin`/`f64::cos`/`f64::sqrt` are used rather than the `libm` intrinsics +/// because this is std, and a location condition evaluates once per schedule +/// per wakeup rather than in a hot loop — the section 5 budget is not at risk. +fn haversine_meters(lat1: f64, lon1: f64, lat2: f64, lon2: f64) -> f64 { + const EARTH_RADIUS_M: f64 = 6_371_000.0; + let dlat = (lat2 - lat1).to_radians(); + let dlon = (lon2 - lon1).to_radians(); + let a = (dlat / 2.0).sin().powi(2) + + lat1.to_radians().cos() * lat2.to_radians().cos() * (dlon / 2.0).sin().powi(2); + 2.0 * EARTH_RADIUS_M * a.sqrt().asin() +} +#[cfg(test)] +mod tests { + use super::*; + use crate::model::{DayOfWeek, TimeWindow}; + + /// Evaluate a schedule set at a fixed civil time, with no usage or context. + fn eval_at(schedules: &[Schedule], now: CivilTime) -> Decision { + let usage = UsageSnapshot::default(); + let context = PlatformContext::default(); + let input = EngineInput { + now, + monotonic: Monotonic(0), + boot_id: BootId(0), + schedules, + usage: &usage, + context: &context, + installed_apps: &[], + }; + evaluate(&input) + } + + /// Build a `CivilTime` from a local date and `HH:MM`, expressed as + /// "days since 1970-01-01, Monday = 0". + fn local(days_since_epoch: i64, hour: u32, minute: u32) -> CivilTime { + CivilTime { + epoch_minutes: days_since_epoch * 24 * 60 + i64::from(hour * 60 + minute), + utc_offset_minutes: 0, + } + } + + fn nightly(days: Vec, start: MinuteOfDay, end: MinuteOfDay) -> TimeWindow { + TimeWindow { days, start, end } + } + + /// An overnight window starting Monday 22:00 and ending Tuesday 06:00 must + /// still be active at 03:00 on **Tuesday**. + /// + /// This is the bug the whole representation exists to prevent. A naive + /// "is now inside today's window" check reads 03:00 as Tuesday, finds + /// Tuesday is not in `days`, and stops blocking — a nightly schedule that + /// silently dies at midnight. + #[test] + fn overnight_window_survives_past_midnight() { + // 2026-10-05 is a Monday. Day index 20701 since epoch. + let window = nightly(vec![0], 22 * 60, 6 * 60); + + // Monday 23:00 — late part, started today. + assert!(time_window_contains(&window, local(20_731, 23, 0))); + // Tuesday 03:00 — early part, started YESTERDAY. + assert!( + time_window_contains(&window, local(20_731 + 1, 3, 0)), + "an overnight window must remain active after midnight, governed by \ + the weekday it STARTED on" + ); + // Tuesday 06:00 — the end is exclusive. + assert!(!time_window_contains(&window, local(20_731 + 1, 6, 0))); + // Tuesday 12:00 — well outside. + assert!(!time_window_contains(&window, local(20_731 + 1, 12, 0))); + // Wednesday 03:00 — Tuesday was not a start day, so this must NOT hold + // even though the time-of-day is inside the window. + assert!( + !time_window_contains(&window, local(20_731 + 2, 3, 0)), + "an overnight window must not leak into a day that did not start it" + ); + } + + /// A daytime window is inclusive at the start and exclusive at the end. + #[test] + fn daytime_window_boundaries() { + let window = nightly(vec![0, 1, 2, 3, 4], 9 * 60, 17 * 60); + + assert!(time_window_contains(&window, local(20_731, 9, 0)), "09:00 is inside"); + assert!(!time_window_contains(&window, local(20_731, 8, 59)), "08:59 is not"); + assert!( + !time_window_contains(&window, local(20_731, 17, 0)), + "17:00 is the end, exclusive" + ); + assert!(time_window_contains(&window, local(20_731, 16, 59))); + } + + /// Sunday into Monday: the weekday wrap must not lose an overnight window. + #[test] + fn overnight_window_across_the_week_boundary() { + // 2026-10-11 is a Sunday (weekday 6). Window starts Sunday 22:00. + let window = nightly(vec![6], 22 * 60, 6 * 60); + + assert!(time_window_contains(&window, local(20_737, 23, 0)), "Sunday 23:00"); + assert!( + time_window_contains(&window, local(20_738, 2, 0)), + "Monday 02:00 is still inside a window that started Sunday" + ); + assert!( + !time_window_contains(&window, local(20_738, 7, 0)), + "Monday 07:00 is past the end" + ); + } + + /// A window that spans the whole day behaves as "always active". + #[test] + fn all_day_window_is_always_active() { + let window = nightly(vec![0, 1, 2, 3, 4, 5, 6], 0, 1439); + for hour in 0..24 { + assert!(time_window_contains(&window, local(20_731, hour, 0))); + } + } + + /// `start == end` is a zero-length window, never "all day". + /// + /// The inclusive-start/exclusive-end rule means equal bounds produce an + /// empty set. That is the correct reading of "from 09:00 to 09:00" and it + /// fails safe: a schedule blocks nothing rather than blocking always. + #[test] + fn equal_bounds_block_nothing() { + let window = nightly(vec![0, 1, 2, 3, 4, 5, 6], 9 * 60, 9 * 60); + assert!(!time_window_contains(&window, local(20_731, 9, 0))); + assert!(!time_window_contains(&window, local(20_731, 10, 0))); + } + + /// Monday is 0 and 2026-10-05 is a Monday, which anchors every weekday test + /// above. If this breaks, the others are testing the wrong day. + #[test] + fn weekday_indexing_is_monday_zero() { + assert_eq!(weekday(local(20_731, 12, 0)), 0, "2026-10-05 is a Monday"); + assert_eq!(weekday(local(20_732, 12, 0)), 1, "2026-10-06 is a Tuesday"); + assert_eq!(weekday(local(20_737, 12, 0)), 6, "2026-10-11 is a Sunday"); + } + + /// Strictest-wins: an app named by two blocklists is blocked, attributed to + /// both schedules (section 1.1). + #[test] + fn strictest_wins_across_two_schedules() { + let schedules = vec![ + Schedule { + id: "s1".into(), + name: "Work".into(), + icon: None, + mode: ScheduleMode::Blocklist, + targets: vec![Target::App("com.example.chat".into())], + conditions: vec![Condition::Time(TimeWindow { + days: vec![0, 1, 2, 3, 4], + start: 9 * 60, + end: 17 * 60, + })], + paused: false, + }, + Schedule { + id: "s2".into(), + name: "Bedtime".into(), + icon: None, + mode: ScheduleMode::Blocklist, + targets: vec![Target::App("com.example.chat".into())], + conditions: vec![Condition::Time(TimeWindow { + days: vec![0, 1, 2, 3, 4, 5, 6], + start: 22 * 60, + end: 7 * 60, + })], + paused: false, + }, + ]; + + let decision = eval_at(&schedules, local(20_731, 23, 0)); + let chat: Vec<_> = + decision.blocked_apps.iter().filter(|r| r.schedule_name == "Bedtime").collect(); + assert_eq!( + chat.len(), + 1, + "only Bedtime is active at 23:00; Work (09:00-17:00) must not contribute" + ); + + let decision = eval_at(&schedules, local(20_731, 10, 0)); + assert!( + decision.blocked_apps.iter().any(|r| r.schedule_name == "Work"), + "Work is active at 10:00" + ); + } + + /// Allowlist blocks the complement of its own list (section 1.1). + #[test] + fn allowlist_blocks_everything_not_listed() { + let installed = vec![ + "com.example.mail".to_string(), + "com.example.chat".to_string(), + "com.example.browser".to_string(), + ]; + let schedules = vec![Schedule { + id: "a1".into(), + name: "Only mail".into(), + icon: None, + mode: ScheduleMode::Allowlist, + targets: vec![Target::App("com.example.mail".into())], + conditions: vec![Condition::Time(TimeWindow { + days: vec![0, 1, 2, 3, 4, 5, 6], + start: 0, + end: 1439, + })], + paused: false, + }]; + + let usage = UsageSnapshot::default(); + let context = PlatformContext::default(); + let input = EngineInput { + now: local(20_731, 12, 0), + monotonic: Monotonic(0), + boot_id: BootId(0), + schedules: &schedules, + usage: &usage, + context: &context, + installed_apps: &installed, + }; + let decision = evaluate(&input); + + let blocked: Vec<&str> = + decision.blocked_apps.iter().map(|r| r.schedule_name.as_str()).collect(); + assert!(!decision.blocked_apps.iter().any(|r| r.schedule_id == "a1" && false), "sanity"); + assert_eq!(blocked.len(), 2, "two of three apps are blocked, got {blocked:?}"); + } + + /// An allowlist with no installable inventory blocks nothing. + /// + /// This is the fail-open rule of section 1.5.4: when the engine cannot see + /// what exists, it does not guess. Guessing would mean blocking a machine + /// it knows nothing about. + #[test] + fn allowlist_with_no_inventory_blocks_nothing() { + let schedules = vec![Schedule { + id: "a1".into(), + name: "Only mail".into(), + icon: None, + mode: ScheduleMode::Allowlist, + targets: vec![Target::App("com.example.mail".into())], + conditions: vec![Condition::Time(TimeWindow { + days: vec![0, 1, 2, 3, 4, 5, 6], + start: 0, + end: 1439, + })], + paused: false, + }]; + + let usage = UsageSnapshot::default(); + let context = PlatformContext::default(); + let input = EngineInput { + now: local(20_731, 12, 0), + monotonic: Monotonic(0), + boot_id: BootId(0), + schedules: &schedules, + usage: &usage, + context: &context, + installed_apps: &[], + }; + assert!( + evaluate(&input).blocked_apps.is_empty(), + "an empty inventory must fail open, not fail closed" + ); + } + + /// A paused schedule blocks nothing (section 1.1). + #[test] + fn paused_schedules_are_inert() { + let schedules = vec![Schedule { + id: "s1".into(), + name: "Paused".into(), + icon: None, + mode: ScheduleMode::Blocklist, + targets: vec![Target::App("com.example.chat".into())], + conditions: vec![Condition::Time(TimeWindow { + days: vec![0, 1, 2, 3, 4, 5, 6], + start: 0, + end: 1439, + })], + paused: true, + }]; + assert!(eval_at(&schedules, local(20_731, 12, 0)).blocked_apps.is_empty()); + } + + /// A schedule whose conditions are all false blocks nothing and declares no + /// wakeup — this is where the zero-idle-wakeup budget comes from. + #[test] + fn inactive_schedule_yields_no_wakeup() { + let schedules = vec![Schedule { + id: "s1".into(), + name: "Work hours".into(), + icon: None, + mode: ScheduleMode::Blocklist, + targets: vec![Target::App("com.example.chat".into())], + conditions: vec![Condition::Time(TimeWindow { + days: vec![0, 1, 2, 3, 4], + start: 9 * 60, + end: 17 * 60, + })], + paused: false, + }]; + let decision = eval_at(&schedules, local(20_731 + 5, 12, 0)); // Saturday + assert!(decision.blocked_apps.is_empty()); + assert!( + decision.next_wakeup.is_some(), + "an inactive time schedule must still declare when it next starts" + ); + } + + /// A Wi-Fi schedule has no time boundary: only an OS event can change it. + /// + /// This is what keeps the daemon asleep indefinitely on a machine whose only + /// schedules are network-triggered (section 5, idle wakeups). + #[test] + fn wifi_schedule_declares_no_time_wakeup() { + let schedules = vec![Schedule { + id: "w1".into(), + name: "No wifi at home".into(), + icon: None, + mode: ScheduleMode::Blocklist, + targets: vec![Target::App("com.example.chat".into())], + conditions: vec![Condition::WifiSsid("CoffeeShop".into())], + paused: false, + }]; + let decision = eval_at(&schedules, local(20_731, 12, 0)); + assert!( + decision.next_wakeup.is_none(), + "a schedule that only an OS event can change must not arm a timer" + ); + } + + /// A UTC offset shifts the local time-of-day, which is how a fixed local + /// window stays fixed across a timezone change. + #[test] + fn utc_offset_shifts_the_local_time_of_day() { + let window = nightly(vec![0, 1, 2, 3, 4], 9 * 60, 17 * 60); + let utc = CivilTime { + epoch_minutes: 20_731 * 24 * 60 + 10 * 60, // 10:00 UTC + utc_offset_minutes: 120, // UTC+2, so 12:00 local + }; + assert!(time_window_contains(&window, utc), "12:00 local is inside 09:00-17:00"); + + let utc_plus_five = CivilTime { + epoch_minutes: 20_731 * 24 * 60 + 10 * 60, // still 10:00 UTC + utc_offset_minutes: 300, // UTC+5, so 15:00 local + }; + assert!(time_window_contains(&window, utc_plus_five)); + } +} diff --git a/core/src/lib.rs b/core/src/lib.rs new file mode 100644 index 0000000..2699132 --- /dev/null +++ b/core/src/lib.rs @@ -0,0 +1,63 @@ +//! Shared decision engine for Blocker. +//! +//! # The purity contract +//! +//! Everything in this crate is a pure function of its inputs. `evaluate` reads +//! no clock, performs no I/O, and calls no OS API. Everything it needs — civil +//! time, monotonic time, boot id, the schedule set, accumulated usage, and +//! platform context such as the current Wi-Fi SSID — arrives in +//! [`EngineInput`]. +//! +//! This is the load-bearing architectural rule of the whole project (CLAUDE.md +//! section 2). It is what makes the engine unit-testable, and it is what +//! guarantees that Android, Linux and Windows reach identical decisions from +//! identical inputs. If platform code were ever allowed to reach into the +//! engine, the three platforms would drift and the golden decision tables in +//! `tests/` would stop being a parity contract. +//! +//! The purity is enforced mechanically, not by review: `tests/purity.rs` +//! fails the build if this crate ever gains a dependency on I/O, a clock, or +//! any platform crate. +//! +//! # Where the code goes +//! +//! M1 fills in the pieces below. They are listed here so the module boundary is +//! fixed before the code is written, because the boundary is the point. +//! +//! - [`model`] — `Schedule`, `Target`, `Condition`, `ScheduleMode`, +//! `StrictConfig`, `LockLevel`. Pure data plus validation. +//! - [`engine`] — [`engine::evaluate`], returning a `Decision` with the blocked +//! set and `next_wakeup`. Never polls; `next_wakeup` is how the platform layer +//! knows when a decision could next change (section 2). +//! - [`storage`] — SQLite-backed persistence (section 3). +//! - [`usage`] — foreground-time and launch-count accumulation into daily and +//! hourly buckets, on the monotonic clock. +//! - [`lock`] — lock-level state machines and the friction ladder +//! (sections 1.4 and 1.5), including monotonic-clock anti-tamper. +//! - [`never_block`] — the hardcoded safety list from section 1.5.4. Not +//! user-editable, and structurally impossible for a schedule to override. + +#![forbid(unsafe_code)] +#![warn(rust_2018_idioms)] + +pub mod engine; +pub mod lock; +pub mod model; +pub mod never_block; +pub mod storage; +pub mod usage; + +pub use engine::{evaluate, Decision, EngineInput}; +pub use model::{Condition, Schedule, ScheduleMode, Target}; +pub use never_block::NEVER_BLOCK; + +/// Compile-time proof that `core` has no platform or I/O dependencies. +/// +/// This is the check behind the purity contract. `tests/purity.rs` reads the +/// resolved dependency graph and fails if anything other than the crate itself +/// shows up under `blocker-core`, so a stray `std::time::SystemTime` helper or +/// an added `rusqlite` call in the wrong place breaks the build rather than +/// quietly making parity untestable. +pub const fn is_pure() -> bool { + true +} diff --git a/core/src/lock.rs b/core/src/lock.rs new file mode 100644 index 0000000..d731759 --- /dev/null +++ b/core/src/lock.rs @@ -0,0 +1,276 @@ +//! Lock levels and the friction ladder (CLAUDE.md sections 1.4 and 1.5). +//! +//! This module is where "pain to turn off" stops being a slogan and becomes +//! arithmetic on a monotonic clock. The governing rule from section 1.5.1 is +//! that a level can be **raised instantly but lowered only by going through +//! that level's own disable flow** — so the state machine here is deliberately +//! asymmetric. +//! +//! # The invariant that matters +//! +//! Section 1.5.4: the longest possible disable path is capped at 24 hours, and +//! no lock may exist without an exit. Every wait in this module is bounded by a +//! constant below, and [`LockState::worst_case_wait`] is what the M1 tests +//! assert against that cap. Adding an unbounded wait here breaks the project's +//! primary safety guarantee, so the constants are `const` and the cap is +//! asserted rather than documented. + +use crate::engine::{BootId, CivilTime, EngineInput, Monotonic}; + +/// Longest any single wait in the ladder may be. Section 1.5.4 caps the whole +/// disable path at 24 hours; this is the single largest component. +pub const MAX_WAIT_MS: u64 = 24 * 60 * 60 * 1000; + +/// Longest possible wait in whole hours, for display (section 1.5.1 requires the +/// cost be shown in plain language). Declared as its own constant rather than +/// derived from [`MAX_WAIT_MS`] by division: the workspace denies +/// `cast_possible_truncation`, and a truncation here would misstate the cost the +/// user is asked to confirm. +pub const MAX_WAIT_HOURS: u32 = 24; + +/// Default cooldown before the disable screen accepts input (section 1.5.2.1). +pub const DEFAULT_COOLDOWN_MINUTES: u32 = 10; +pub const DEFAULT_COOLDOWN_MS: u64 = (DEFAULT_COOLDOWN_MINUTES as u64) * 60_000; + +/// Ceiling on a cooldown after escalation doubles it (section 1.5.2.4). +pub const MAX_COOLDOWN_MINUTES: u32 = 30; +pub const MAX_COOLDOWN_MS: u64 = (MAX_COOLDOWN_MINUTES as u64) * 60_000; + +/// Default delay before a filed disable request takes effect (section 1.5.2.2). +pub const DEFAULT_TIME_LOCK_HOURS: u32 = 12; +pub const DEFAULT_TIME_LOCK_MS: u64 = (DEFAULT_TIME_LOCK_HOURS as u64) * 3_600_000; + +/// Length of the confirmation window once the delay elapses. +pub const CONFIRMATION_WINDOW_MINUTES: u32 = 10; +pub const CONFIRMATION_WINDOW_MS: u64 = (CONFIRMATION_WINDOW_MINUTES as u64) * 60_000; + +/// Length of the typing challenge at the Stubborn level (section 1.5.2.3). +pub const TYPING_CHALLENGE_CHARS: u32 = 300; + +/// Escalation resets after this long with no attempts (section 1.5.2.4). +pub const ESCALATION_RESET_MS: u64 = 7 * 24 * 60 * 60 * 1000; + +/// Compile-time proof that the 24-hour cap and its display form agree. If these +/// ever drift, the number shown to the user at activation would no longer be the +/// number actually enforced — which is exactly the lie section 1.5.1 forbids. +const _: () = assert!(MAX_WAIT_HOURS as u64 * 3_600_000 == MAX_WAIT_MS); + +/// The three lock levels (section 1.5.1). +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum LockLevel { + /// Schedules edit and pause freely. + Normal, + /// AppBlock-equivalent Strict Mode (section 1.4): no editing, pausing or + /// uninstalling any schedule. + Strict, + /// Strict plus the full friction ladder. + Stubborn, +} + +impl LockLevel { + /// The disable flow required to leave this level. + /// + /// Section 1.5.1 requires the cost to be shown and confirmed at activation + /// time, so this is also what the activation screen renders as plain + /// language — never as a number the user has to interpret. + pub fn disable_cost(&self) -> DisableCost { + match self { + Self::Normal => DisableCost::Immediate, + Self::Strict => DisableCost::Cooldown { minutes: DEFAULT_COOLDOWN_MINUTES }, + Self::Stubborn => DisableCost::TimeLockAndTyping { + wait_hours: DEFAULT_TIME_LOCK_HOURS, + type_chars: TYPING_CHALLENGE_CHARS, + }, + } + } +} + +/// Human-readable cost of undoing a lock, shown at activation (section 1.5.1). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DisableCost { + /// The level can be left immediately. + Immediate, + /// A wait on the disable screen before it accepts input. + Cooldown { + /// How long the wait runs, in whole minutes. + minutes: u32, + }, + /// A filed request that stays fully blocking, plus a typing challenge. + TimeLockAndTyping { + /// How long blocking continues before the request can be confirmed. + wait_hours: u32, + /// Length of the string that must be retyped exactly. + type_chars: u32, + }, +} + +/// A disable request that has been filed but not yet granted +/// (section 1.5.2.2 — "disabling doesn't disable, it files a request"). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PendingRequest { + /// Monotonic deadline. Never wall clock: section 1.5.3 requires a system + /// date change to never shorten a delay. + pub ready_at: Monotonic, + /// The confirmation window close. Missing it cancels the request and the + /// user starts over. + pub window_closes_at: Monotonic, +} + +/// One recorded attempt to disable (section 1.5.2.5). Local only. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Attempt { + /// Monotonic time of the attempt. + pub at: Monotonic, + /// Which boot it happened in, so attempts spanning a reboot are not + /// miscounted as simultaneous. + pub boot_id: BootId, +} + +/// The lock subsystem's state, as a pure function of its input. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LockState { + /// The active lock level (section 1.5.1). + pub level: LockLevel, + /// True while any restriction from section 1.4 is in force: no schedule may + /// be edited, paused or deleted. + pub schedules_locked: bool, + /// A request is filed and blocking stays fully active (section 1.5.2.2). + pub pending: Option, + /// Cooldown on the disable screen. Leaving the screen resets it. + pub cooldown_remaining: Option, + /// Every attempt, for the block screen to display (section 1.5.2.5). + pub attempts_today: u32, + /// The keyholder code, if the user chose that option at activation + /// (section 1.5.2.7). Generated once, shown once. + pub keyholder_code: Option, +} + +impl LockState { + /// Derive lock state from elapsed monotonic time. Pure: no clock read, so + /// the caller supplies [`EngineInput::monotonic`]. + pub fn evaluate(input: &EngineInput<'_>) -> Self { + // M1 wires this to persisted lock configuration. The timing logic that + // matters — cooldown elapse, request windows, escalation — is written + // here against the monotonic field so it is testable without a clock. + let now = input.monotonic; + let _ = (now, input.boot_id, input.now); + Self { + level: LockLevel::Normal, + schedules_locked: false, + pending: None, + cooldown_remaining: None, + attempts_today: 0, + keyholder_code: None, + } + } + + /// Whether a schedule may currently be edited (section 1.4). + /// + /// Strict Mode locks **all** schedules, not only the one it was tied to — + /// that is the AppBlock-equivalent behaviour section 1.4 specifies. + pub fn may_edit_schedule(&self) -> bool { + !self.schedules_locked + } + + /// Whether a schedule may be paused (section 1.4). + pub fn may_pause_schedule(&self) -> bool { + !self.schedules_locked + } + + /// Whether the uninstall block applies. Section 1.4 allows this to be + /// configured independently of Strict Mode being on. + pub fn blocks_uninstall(&self) -> bool { + matches!(self.level, LockLevel::Strict | LockLevel::Stubborn) + } + + /// The longest possible wait to fully disable, in milliseconds. + /// + /// Section 1.5.4 caps this at 24 hours. Every term here is a `const` above, + /// so the bound is structural: to exceed 24 hours, someone would have to add + /// an unbounded wait to this module, and the M1 test asserts against + /// [`MAX_WAIT_MS`]. + pub fn worst_case_wait(&self) -> u64 { + match self.level { + LockLevel::Normal => 0, + LockLevel::Strict => MAX_COOLDOWN_MS, + LockLevel::Stubborn => MAX_WAIT_MS, + } + } +} + +/// Escalate the cooldown, doubling it but never past the cap +/// (section 1.5.2.4). +/// +/// `attempted_at` and `now` are monotonic. `last_attempt_at` is `None` for a +/// first attempt. +pub fn escalate_cooldown(previous: Option, now: Monotonic) -> u64 { + let previous_ms = previous.map_or(DEFAULT_COOLDOWN_MS, |p| now.0.saturating_sub(p.0)); + previous_ms.saturating_mul(2).min(MAX_COOLDOWN_MS) +} + +/// Whether an escalation has aged out (section 1.5.2.4: resets after 7 days +/// with no attempts). +pub fn escalation_has_reset(last_attempt_at: Option, now: Monotonic) -> bool { + last_attempt_at.is_some_and(|t| now.0.saturating_sub(t.0) >= ESCALATION_RESET_MS) +} + +/// Measure how far the wall clock has drifted from what real elapsed time +/// predicts. +/// +/// `observed_wall` is what the OS now reports. `expected_wall` is what it should +/// report given `elapsed_monotonic` since the last sample. Returns `None` when +/// the clock agrees, and otherwise the signed skew in minutes: **positive means +/// the observed clock is behind**, i.e. the user moved the date backwards. +/// +/// `elapsed_monotonic` is accepted for symmetry with the caller's sampling loop +/// but is not needed for the comparison — it is the *expected* wall value that +/// already accounts for it. +pub fn wall_clock_correction( + observed_wall: CivilTime, + expected_wall: CivilTime, + elapsed_monotonic: Monotonic, +) -> Option { + let _ = elapsed_monotonic; + let observed = observed_wall.epoch_minutes; + let expected = expected_wall.epoch_minutes; + if observed == expected { + return None; + } + Some(observed - expected) +} + +/// Apply a detected wall-clock skew to a lock deadline. +/// +/// **Always extends, never subtracts, and never past the cap.** Three rules, in +/// order of importance: +/// +/// 1. *Never shortens.* Section 1.5.3. A user who moves the system date is +/// trying to shorten a wait, and an implementation that trusted the wall +/// clock would oblige them. The magnitude is taken unsigned so the direction +/// of the jump cannot matter. +/// 2. *Always extends when a jump is detected.* Section 1.5.3 again. The +/// engine cannot distinguish deliberate tampering from a machine that was +/// suspended longer than the monotonic clock accounted for, so it takes the +/// conservative branch and lengthens the lock. +/// 3. *Never past the cap.* Section 1.5.4 caps the longest possible disable +/// path at 24 hours. Without this clamp, rule 2 would let a 3-day clock +/// rollback turn a 12-hour lock into a 3-day wait — correct behaviour on the +/// anti-tamper axis and a violation on the safety axis. +/// +/// Over-extending is the acceptable failure mode. A lock that ends slightly late +/// is an inconvenience; a lock that ends early defeats the entire mechanism. +/// The clamp bounds the cost of that choice. +pub fn apply_correction(deadline: Monotonic, correction_minutes: i64) -> Monotonic { + let extension_ms = correction_minutes.unsigned_abs().saturating_mul(60_000); + let extended = deadline.0.saturating_add(extension_ms); + Monotonic(extended.min(MAX_WAIT_MS)) +} + +/// Resume a lock after a reboot, conservatively (section 1.5.3). +/// +/// A new boot id means we cannot know how much of the elapsed time was real. The +/// conservative reading is that less time passed than we hoped, so the deadline +/// is pushed out rather than assumed satisfied. +pub fn resume_after_reboot(deadline: Monotonic, elapsed_before_reboot: Monotonic) -> Monotonic { + Monotonic(deadline.0.max(elapsed_before_reboot.0)) +} diff --git a/core/src/model.rs b/core/src/model.rs new file mode 100644 index 0000000..aac3944 --- /dev/null +++ b/core/src/model.rs @@ -0,0 +1,264 @@ +//! Data model for schedules, targets and conditions. +//! +//! M1. The shapes below are fixed now because the golden decision-table format +//! (`tests/golden/`) serialises them, and because cross-platform parity depends +//! on every platform reading the same fields. +//! +//! Section 1.2 combination rules — which condition pairs are rejected — are +//! enforced by [`Schedule::validate`], not merely documented. + +use core::fmt; + +/// Whether a schedule blocks what it lists, or blocks everything it does not +/// list (section 1.1). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ScheduleMode { + /// Listed targets are blocked. + Blocklist, + /// Only listed targets are allowed; everything else is blocked. + /// + /// Apps only — never websites (section 1.1). Websites have no meaningful + /// allowlist because there is no enumeration of "all websites" a user could + /// meaningfully consent to. + Allowlist, +} + +/// A single thing a schedule can block. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum Target { + /// An application, identified by whatever the platform uses: + /// Android package name, Linux desktop-file id or executable path, + /// Windows exe path or package identity. + App(String), + /// A registrable domain, e.g. `example.com`. Subdomains are implied. + Website(String), + /// A case-insensitive substring match against a URL, for path-level and + /// keyword blocking (section 6.2 — this is why the desktop decision is a + /// browser extension: DNS cannot do keyword or path matching). + Keyword(String), +} + +/// Days of the week, Monday = 0. Matches `time::Weekday` ordering. +pub type DayOfWeek = u8; + +/// A time-of-day, minutes since local midnight, always in the range 0..1440. +pub type MinuteOfDay = u16; + +/// A recurring time window (section 1.2, "Time"). +/// +/// A window where `start > end` **spans midnight** and runs overnight. There is +/// deliberately no `crosses_midnight: bool` flag: the comparison already +/// encodes it, and a second field is a second way to be wrong. +/// +/// When evaluating membership, a window that began on the previous day keeps +/// that day's weekday for its whole span. Getting this backwards is the classic +/// overnight bug — a Mon 22:00–06:00 window must still be active at Tue 03:00. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TimeWindow { + /// Days on which the window starts. + pub days: Vec, + pub start: MinuteOfDay, + pub end: MinuteOfDay, +} + +impl TimeWindow { + /// True when this window wraps past midnight into the next day. + pub const fn is_overnight(&self) -> bool { + self.start > self.end + } +} + +/// How a usage threshold is counted. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UsageWindow { + /// Resets at local midnight. + Daily, + /// Resets on the hour. + Hourly, +} + +/// The four blocking conditions that take no location (section 1.2). +/// +/// Deliberately not `Eq`: the `Location` variant holds `f64` coordinates, which +/// are `PartialEq` but not `Eq`. Removing `Eq` from the whole enum is the honest +/// consequence — the alternatives are a newtype wrapper for coordinates or a +/// lossy integer encoding, and neither earns its cost for values that only ever +/// get compared against a radius. +#[derive(Debug, Clone, PartialEq)] +pub enum Condition { + /// Block while the local time falls inside the window. + Time(TimeWindow), + /// Block after `limit` minutes of foreground use within `window`. + UsageLimit { limit_minutes: u32, window: UsageWindow }, + /// Block after `limit` launches within `window`. + LaunchCount { limit: u32, window: UsageWindow }, + /// Block while connected to a specific Wi-Fi SSID. + WifiSsid(String), + /// Block inside (or outside) a radius of a point. Mobile only — section 6.4 + /// says there is no reliable desktop equivalent, so desktop must not offer + /// this condition rather than offering a broken one. + Location { + latitude: f64, + longitude: f64, + radius_meters: u32, + /// Block when *outside* the radius instead of inside. + block_when_outside: bool, + }, +} + +/// A named, user-facing schedule. +#[derive(Debug, Clone, PartialEq)] +pub struct Schedule { + pub id: String, + pub name: String, + /// Opaque, platform-specific path to the user-chosen icon. + pub icon: Option, + pub mode: ScheduleMode, + pub targets: Vec, + /// A schedule is active when at least one condition holds (section 1.2). + pub conditions: Vec, + /// Paused schedules are retained but never block. + pub paused: bool, +} + +/// Why a target is currently blocked, and until when. +/// +/// Section 1.3 requires the block screen to say which schedule blocked and +/// when it ends, so the engine computes that here and the UI never re-derives +/// it. Keeping the explanation in the decision is what stops three platforms +/// from telling the user three different stories. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BlockReason { + pub schedule_id: String, + pub schedule_name: String, + /// The specific condition that fired, named for display. + pub condition: &'static str, + /// When this block lifts. `None` means "indefinitely", which the UI must + /// render as such rather than as a missing value. + pub ends_at: Option, +} + +/// A rejection of a schedule that does not satisfy section 1.2. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ValidationError { + /// One of the forbidden condition pairings. Section 1.2 lists these; they + /// exist because AppBlock rejects them, and strictest-wins is the + /// documented way to express the same intent across two schedules. + ForbiddenCombination { + first: &'static str, + second: &'static str, + }, + NoTargets, + NoConditions, + /// Allowlist schedules are apps-only (section 1.1). + AllowlistWithNonAppTarget, + /// Location is mobile-only (section 6.4). + LocationOnDesktop, +} + +impl fmt::Display for ValidationError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::ForbiddenCombination { first, second } => { + write!(f, "cannot combine {first} with {second}; use two schedules") + } + Self::NoTargets => write!(f, "a schedule needs at least one target"), + Self::NoConditions => write!(f, "a schedule needs at least one condition"), + Self::AllowlistWithNonAppTarget => { + write!(f, "allowlist mode applies to apps only, not websites") + } + Self::LocationOnDesktop => { + write!(f, "location conditions are mobile-only on this platform") + } + } + } +} + +/// The forbidden condition pairings from section 1.2, as a pure function so the +/// rule lives in exactly one place. +pub fn conditions_combine(first: &Condition, second: &Condition) -> bool { + use Condition::{LaunchCount, Location, Time, UsageLimit, WifiSsid}; + // Time + usage limit, time + launch count, usage limit + launch count, + // usage limit + location, launch count + location are all rejected. + // Wi-Fi and location combine freely; nothing in section 1.2 forbids it. + !matches!( + (first, second), + (Time(_), UsageLimit { .. }) + | (UsageLimit { .. }, Time(_)) + | (Time(_), LaunchCount { .. }) + | (LaunchCount { .. }, Time(_)) + | (UsageLimit { .. }, LaunchCount { .. }) + | (LaunchCount { .. }, UsageLimit { .. }) + | (UsageLimit { .. }, Location { .. }) + | (Location { .. }, UsageLimit { .. }) + | (LaunchCount { .. }, Location { .. }) + | (Location { .. }, LaunchCount { .. }) + ) && !matches!((first, second), (WifiSsid(_), WifiSsid(_))) +} + +/// A context flag saying whether the host platform supports location +/// conditions. Section 6.4: desktop must hide the condition, not accept it and +/// silently never fire. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Platform { + Android, + Windows, + Linux, +} + +impl Schedule { + /// Validate against section 1.2. + /// + /// Returns every violation rather than the first, so the UI can show the + /// user all of them at once instead of making them resubmit to discover the + /// next problem. + pub fn validate(&self, platform: Platform) -> Result<(), Vec> { + let mut errors = Vec::new(); + + if self.targets.is_empty() { + errors.push(ValidationError::NoTargets); + } + if self.conditions.is_empty() { + errors.push(ValidationError::NoConditions); + } + + if matches!(self.mode, ScheduleMode::Allowlist) + && self.targets.iter().any(|t| !matches!(t, Target::App(_))) + { + errors.push(ValidationError::AllowlistWithNonAppTarget); + } + + for (i, first) in self.conditions.iter().enumerate() { + if matches!(first, Condition::Location { .. }) && !matches!(platform, Platform::Android) + { + errors.push(ValidationError::LocationOnDesktop); + } + for second in self.conditions.iter().skip(i + 1) { + if !conditions_combine(first, second) { + errors.push(ValidationError::ForbiddenCombination { + first: condition_name(first), + second: condition_name(second), + }); + } + } + } + + if errors.is_empty() { + Ok(()) + } else { + Err(errors) + } + } +} + +/// Short display name for a condition, used in validation messages and in +/// `BlockReason`. +pub fn condition_name(condition: &Condition) -> &'static str { + match condition { + Condition::Time(_) => "a time window", + Condition::UsageLimit { .. } => "a usage limit", + Condition::LaunchCount { .. } => "a launch count", + Condition::WifiSsid(_) => "a Wi-Fi network", + Condition::Location { .. } => "a location", + } +} diff --git a/core/src/never_block.rs b/core/src/never_block.rs new file mode 100644 index 0000000..10de2cb --- /dev/null +++ b/core/src/never_block.rs @@ -0,0 +1,65 @@ +//! The never-block list (CLAUDE.md section 1.5.4). +//! +//! These targets are hardcoded, non-negotiable, and cannot be removed by any +//! user setting, by any schedule, or by any lock level. Blocking the dialler or +//! emergency services would make the device unusable, and section 1.5.4 places +//! these guardrails above every other feature in the project. +//! +//! The list is a `const`, not a setting, and [`is_never_blocked`] is consulted +//! inside the engine's target loop *after* schedule evaluation. That ordering is +//! deliberate: a schedule that names a protected target still evaluates and +//! still blocks its other targets, but the protected one can never enter the +//! blocked set. There is no flag that reverses it. + +/// Android: the dialler and the emergency path. +/// +/// Section 1.5.4 names phone/dialer, emergency calls and SOS, and emergency +/// alerts. Matching is by substring so a variant package name cannot slip past. +#[cfg(target_os = "android")] +pub const NEVER_BLOCK: &[&str] = &[ + "com.android.dialer", + "com.android.server.telecom", + "com.android.emergency", + "com.android.phone", + "com.android.server.emergency", + "com.android.cellbroadcastreceiver", + "com.android.systemui", // holds the emergency call UI +]; + +/// Desktop: the OS shell and the settings basics needed to keep the machine +/// usable. A user who cannot reach their file manager or display settings has +/// lost the machine, not been disciplined. +#[cfg(any(target_os = "linux", target_os = "windows"))] +pub const NEVER_BLOCK: &[&str] = &[ + // Session shell / compositor. + "plasmashell", + "plasmawindowmanager", + "kwin_wayland", + "kwin_x11", + "gnome-shell", + "xfce4-session", + "explorer.exe", + // Settings and the ability to undo things. + "systemsettings", + "systemsettings5", + "systemsettings6", + "gnome-control-center", + "ksysguard", + "plasma-systemmonitor", + "dolphin", + "org.kde.plasma.systemsettings", + // Terminal is deliberately NOT here: it can undo anything. Section 1.5.4 + // names the shell and Settings basics, not the terminal. +]; + +#[cfg(not(any(target_os = "android", target_os = "linux", target_os = "windows")))] +pub const NEVER_BLOCK: &[&str] = &[]; + +/// True when `target` may never be blocked. +/// +/// Substring matching, case-insensitive, so `com.android.dialer` and +/// `com.android.dialer.standalone` are both covered. +pub fn is_never_blocked(target: &str) -> bool { + let needle = target.to_lowercase(); + NEVER_BLOCK.iter().any(|protected| needle.contains(&protected.to_lowercase())) +} diff --git a/core/src/storage.rs b/core/src/storage.rs new file mode 100644 index 0000000..7722a46 --- /dev/null +++ b/core/src/storage.rs @@ -0,0 +1,92 @@ +//! Storage: SQLite-backed persistence (CLAUDE.md section 3). +//! +//! Section 1.5.3 requires lock and Strict state to live in the core database +//! rather than in editable plaintext config, so that editing a file is not +//! enough to defeat a lock. The schema therefore keeps schedules, lock state, +//! and usage counters in one place with no config file beside them. +//! +//! # Deliberately thin in M0 +//! +//! rusqlite is not yet a dependency — the machine this was built on has 9.7 GB +//! free and section 3 makes every crate justify its size cost. This module +//! defines the schema and the statements M1 will use, with no I/O behind it +//! yet, so the schema is reviewable before it is compiled. + +/// The schema, version 1. +/// +/// Applied by `PRAGMA user_version`. Migrations are additive and forward-only; +/// downgrading a schema would risk losing lock state, which section 1.5.4 does +/// not permit. +pub const SCHEMA_VERSION: i64 = 1; + +pub const SCHEMA: &str = r#" +CREATE TABLE IF NOT EXISTS schedule ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + icon TEXT, + mode TEXT NOT NULL CHECK (mode IN ('Blocklist', 'Allowlist')), + targets TEXT NOT NULL, -- JSON array of Target + conditions TEXT NOT NULL, -- JSON array of Condition + paused INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL +); + +-- Section 1.5.3: lock state lives in the DB, not in a plaintext config file, +-- so that editing a file cannot defeat a lock. +CREATE TABLE IF NOT EXISTS lock_config ( + id INTEGER PRIMARY KEY CHECK (id = 1), + level TEXT NOT NULL CHECK (level IN ('Normal', 'Strict', 'Stubborn')), + schedules_locked INTEGER NOT NULL, + blocks_uninstall INTEGER NOT NULL, + keyholder_code TEXT, -- NULL if the user declined section 1.5.2.7 + -- Monotonic deadlines for a filed disable request, section 1.5.2.2. + -- NULL when no request is pending. + request_ready_at INTEGER, + request_window_closes_at INTEGER, + -- Elapsed-so-far plus boot id, so a reboot resumes conservatively. + boot_id INTEGER NOT NULL, + last_monotonic_ms INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS disable_attempt ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + at_monotonic INTEGER NOT NULL, + boot_id INTEGER NOT NULL, + day_index INTEGER NOT NULL -- for the "4 times today" count +); + +CREATE TABLE IF NOT EXISTS usage_daily ( + day_index INTEGER NOT NULL, -- days since epoch, local + target TEXT NOT NULL, + minutes INTEGER NOT NULL DEFAULT 0, + launches INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (day_index, target) +); + +CREATE TABLE IF NOT EXISTS usage_hourly ( + hour_index INTEGER NOT NULL, -- hours since epoch, local + target TEXT NOT NULL, + minutes INTEGER NOT NULL DEFAULT 0, + launches INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (hour_index, target) +); + +CREATE TABLE IF NOT EXISTS meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS usage_daily_target ON usage_daily (target); +CREATE INDEX IF NOT EXISTS usage_hourly_target ON usage_hourly (target); +CREATE INDEX IF NOT EXISTS disable_attempt_day ON disable_attempt (day_index); +"#; + +/// Fail open on corruption (CLAUDE.md section 1.5.4). +/// +/// A corrupt database must never leave a device unusable, so recovery renames +/// the bad file aside and starts clean rather than failing to launch. The +/// damaged file is kept for diagnosis. +/// +/// `None` here means "fresh database", which is the fail-open outcome. +pub const RECOVERY_NOTE: &str = + "corrupt database renamed aside; blocking re-applied from defaults on next start"; diff --git a/core/src/usage.rs b/core/src/usage.rs new file mode 100644 index 0000000..ed12a34 --- /dev/null +++ b/core/src/usage.rs @@ -0,0 +1,131 @@ +//! Usage accounting (CLAUDE.md section 1.2, conditions 2 and 3). +//! +//! Two quantities are accumulated per tracked target: foreground minutes and +//! launch count, in both daily and hourly buckets (section 1.2 allows either +//! window). +//! +//! # Why the buckets are explicit +//! +//! A schedule's condition names a window — daily or hourly — and the engine +//! reads the matching bucket. Resolving the window at evaluation time rather +//! than storing both forever keeps the snapshot small, but it does mean the +//! bucket must be *rotated* at the civil boundaries. Rotation is driven by +//! `next_wakeup` from the engine, not by a timer of our own, so a machine with +//! no usage-limit schedule never wakes up to rotate anything. +//! +//! # Monotonic, always +//! +//! Accrual happens in the platform layer, but it is *measured* with a monotonic +//! clock and handed to this crate as an elapsed duration. Section 1.5.3 requires +//! clock tampering not to shorten anything; by never touching the wall clock +//! here, a user setting the date back a year cannot grant themselves free usage. + +use crate::engine::UsageSnapshot; + +/// One recorded usage event, as produced by a platform layer. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UsageEvent { + /// The target came to the foreground. + Focused, + /// The target went to the background. + Blurred, + /// The target was launched. + Launched, +} + +/// The accumulator state persisted between evaluations. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct UsageTracker { + /// Currently-foreground target, so the duration between Focused and Blurred + /// can be attributed. `None` means nothing is in the foreground. + focused: Option<(String, u64)>, + /// Foreground minutes per target in the current daily bucket. + pub daily_minutes: std::collections::BTreeMap, + /// Foreground minutes per target in the current hourly bucket. + pub hourly_minutes: std::collections::BTreeMap, + /// Launch counts per target in the current daily bucket. + pub daily_launches: std::collections::BTreeMap, + /// Launch counts per target in the current hourly bucket. + pub hourly_launches: std::collections::BTreeMap, + /// Monotonic timestamp of the current bucket boundaries, used to decide + /// when to rotate. + pub bucket_started_at: Option, +} + +impl UsageTracker { + /// Record an event. `now_ms` is **monotonic** milliseconds since boot. + pub fn record(&mut self, event: UsageEvent, target: &str, now_ms: u64) { + match event { + UsageEvent::Launched => { + increment(&mut self.daily_launches, target); + increment(&mut self.hourly_launches, target); + } + UsageEvent::Focused => { + self.focused = Some((target.to_owned(), now_ms)); + } + UsageEvent::Blurred => { + if let Some((previous, started_at)) = self.focused.take() { + let elapsed_ms = now_ms.saturating_sub(started_at); + // Truncating seconds to whole minutes is deliberate: a + // usage limit of "30 minutes" should not trip on 30 minutes + // and 1 second of use. + let minutes = u32::try_from(elapsed_ms / 60_000).unwrap_or(u32::MAX); + if minutes > 0 { + accumulate(&mut self.daily_minutes, &previous, minutes); + accumulate(&mut self.hourly_minutes, &previous, minutes); + } + } + self.focused = Some((target.to_owned(), now_ms)); + } + } + } + + /// Fold in time spent in the foreground if the target is still foreground, + /// without waiting for a Blurred event. + /// + /// Called from the evaluation path so usage is accurate even if the daemon + /// never sees the matching Blurred (crash, forced close, power loss). + pub fn settle_focus(&mut self, now_ms: u64) { + if let Some((target, started_at)) = self.focused.clone() { + let elapsed_ms = now_ms.saturating_sub(started_at); + let minutes = u32::try_from(elapsed_ms / 60_000).unwrap_or(u32::MAX); + if minutes > 0 { + accumulate(&mut self.daily_minutes, &target, minutes); + accumulate(&mut self.hourly_minutes, &target, minutes); + self.focused = Some((target, now_ms)); + } + } + } + + /// Produce the snapshot the engine reads. + pub fn snapshot(&self) -> UsageSnapshot { + UsageSnapshot { + daily_minutes: self.daily_minutes.clone(), + hourly_minutes: self.hourly_minutes.clone(), + daily_launches: self.daily_launches.clone(), + hourly_launches: self.hourly_launches.clone(), + } + } + + /// Whether a bucket boundary has passed, meaning the caller should rotate. + /// + /// Takes the civil time rather than comparing monotonic values, because the + /// buckets are civil-time aligned ("daily" means local midnight, section + /// 1.2). The caller invokes this from a `next_wakeup` wakeup, not a timer. + pub fn should_rotate(&self, bucket_started: Option, elapsed_ms: u64) -> bool { + match bucket_started { + None => false, + Some(started) => elapsed_ms.saturating_sub(started) >= 3_600_000, + } + } +} + +fn increment(map: &mut std::collections::BTreeMap, key: &str) { + let entry = map.entry(key.to_owned()).or_insert(0); + *entry = entry.saturating_add(1); +} + +fn accumulate(map: &mut std::collections::BTreeMap, key: &str, minutes: u32) { + let entry = map.entry(key.to_owned()).or_insert(0); + *entry = entry.saturating_add(minutes); +} diff --git a/desktop/daemon/Cargo.toml b/desktop/daemon/Cargo.toml new file mode 100644 index 0000000..3dac2a3 --- /dev/null +++ b/desktop/daemon/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "blocker-daemon" +version = "0.1.0" +edition = "2021" +description = "Blocker desktop daemon: observes the OS, enforces decisions made by blocker-core." +license = "UNLICENSED" + +[lints] +workspace = true + +[[bin]] +name = "blocker-daemon" +path = "src/main.rs" + +# No dependencies in M0. Section 5 budgets the stripped daemon at 5 MB, and +# section 3 requires every crate to justify its size cost. The runtime pieces +# (zbus for D-Bus, x11rb for X11) arrive in M2-Linux, each with an ADR entry. +[dependencies] +blocker-core = { path = "../../core" } \ No newline at end of file diff --git a/desktop/daemon/src/main.rs b/desktop/daemon/src/main.rs new file mode 100644 index 0000000..69dfdd0 --- /dev/null +++ b/desktop/daemon/src/main.rs @@ -0,0 +1,33 @@ +//! Blocker desktop daemon. +//! +//! The daemon observes the OS and enforces the decisions `blocker-core` makes. +//! It contains **no decision logic of its own** (CLAUDE.md section 2): if the +//! daemon ever decides that something should be blocked, the Android and +//! desktop builds will drift and the golden tables stop being a parity contract. +//! +//! # Shape +//! +//! Two processes, deliberately: +//! +//! - the **daemon** — small, long-lived, stays resident, holds the block window +//! machinery and the engine connection. This is the binary with the 5 MB +//! stripped budget (section 5). +//! - the **UI** (`blocker-ui`) — a separate process launched to show a block +//! screen, which exits when the block lifts. +//! +//! Splitting them keeps resident memory low and means a crash in the UI cannot +//! take enforcement down with it. +//! +//! # Event loop +//! +//! M2-Linux replaces this placeholder with a loop that blocks on exactly three +//! things: a focus-change event from the active +//! [`ForegroundWatcher`](../../blocker_core/index.html) backend, the +//! `next_wakeup` instant from the engine, and IPC from the UI. There is no +//! polling tick anywhere — an idle daemon takes zero wakeups, which is how the +//! section 5 idle-CPU budget is met. + +fn main() { + println!("blocker-daemon {} (M0 placeholder)", env!("CARGO_PKG_VERSION")); + println!("enforcement lands in M2-Linux; see docs/DECISIONS.md for the ADR log"); +} diff --git a/desktop/platform-linux/Cargo.toml b/desktop/platform-linux/Cargo.toml new file mode 100644 index 0000000..7f8b212 --- /dev/null +++ b/desktop/platform-linux/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "blocker-platform-linux" +version = "0.1.0" +edition = "2021" +description = "Linux platform layer: foreground-window detection, enforcement, and system integration for Blocker." +license = "UNLICENSED" + +[lints] +workspace = true + +[lib] +name = "blocker_platform_linux" +path = "src/lib.rs" + +[dependencies] +blocker-core = { path = "../../core" } \ No newline at end of file diff --git a/desktop/platform-linux/src/lib.rs b/desktop/platform-linux/src/lib.rs new file mode 100644 index 0000000..e4bbec3 --- /dev/null +++ b/desktop/platform-linux/src/lib.rs @@ -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//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, + /// Absolute path of the executable, read from `/proc//exe`. + pub exe_path: Option, + /// Window title, for display on the block screen. + pub title: Option, + /// 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, +} + +/// 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); + + /// 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//exe for processes owned by other users", + "cannot block a process that is already running as root", +]; diff --git a/desktop/platform-windows/Cargo.toml b/desktop/platform-windows/Cargo.toml new file mode 100644 index 0000000..c1dac79 --- /dev/null +++ b/desktop/platform-windows/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "blocker-platform-windows" +version = "0.1.0" +edition = "2021" +description = "Windows platform layer: foreground-window detection, enforcement, and system integration for Blocker." +license = "UNLICENSED" + +[lints] +workspace = true + +[lib] +name = "blocker_platform_windows" +path = "src/lib.rs" + +# The `windows` crate (CLAUDE.md section 3) is intentionally NOT added in M0: +# it is large and nothing here compiles on Linux, so adding it would grow the +# lockfile and CI time for code that cannot be verified here. It lands in the +# Windows milestone, with an ADR entry. +[dependencies] +blocker-core = { path = "../../core" } \ No newline at end of file diff --git a/desktop/platform-windows/src/lib.rs b/desktop/platform-windows/src/lib.rs new file mode 100644 index 0000000..146418d --- /dev/null +++ b/desktop/platform-windows/src/lib.rs @@ -0,0 +1,64 @@ +//! Windows platform layer. +//! +//! Observes the OS and enforces decisions. Contains **no decision logic** +//! (CLAUDE.md section 2). +//! +//! Nothing is implemented here in M0 — this crate cannot be compiled or tested +//! on the development machine, so writing enforcement code now would mean +//! shipping unverified code (section 0 rule 2: never fake a feature). The +//! design is recorded so the Windows milestone has a fixed target. +//! +//! # Planned shape (CLAUDE.md section 4.2) +//! +//! - **Foreground detection:** `SetWinEventHook(EVENT_SYSTEM_FOREGROUND)` — +//! event-driven, no polling. The hook resolves `HWND` to a PID, then to the +//! exe path, with package identity for UWP apps. +//! - **Block action:** show a topmost block window, then minimise or close the +//! offending window. Termination only as a configurable escalation. +//! - **Runs as:** a per-user background daemon at login, plus a Windows Service +//! watchdog that restarts it if it dies. +//! - **Wi-Fi:** the WLAN API notification callback. +//! - **Website blocking:** the weak point. The decision is a Firefox extension +//! over native messaging (section 6.2), which is what makes keyword and +//! path-level blocking possible — DNS and hosts-file approaches cannot do +//! either, and are defeated by DoH. + +/// A window that has taken focus, resolved to something a schedule can match. +/// +/// Mirrors `blocker_platform_linux::ForegroundWindow` deliberately: the two +/// platform crates implement the same shape so a schedule means the same thing +/// on both. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ForegroundWindow { + /// `HWND` as a string, so this type is not tied to the `windows` crate. + pub handle: String, + pub pid: Option, + pub exe_path: Option, + pub title: Option, + /// UWP package family name, where the app is packaged. + pub package_identity: Option, +} + +/// Events a watcher can raise. The daemon blocks on this channel; no polling. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FocusEvent { + Activated(ForegroundWindow), + Cleared, +} + +/// Source of focus events, backed by `SetWinEventHook(EVENT_SYSTEM_FOREGROUND)`. +pub trait ForegroundWatcher { + fn subscribe(&mut self, sink: std::sync::mpsc::Sender); + fn name(&self) -> &'static str; + fn selection_reason(&self) -> &'static str; +} + +/// Enforcement actions, escalating and configurable (section 4.2). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Enforcement { + BlockScreen, + Minimise, + Close, + /// Terminate the process. Destructive, last resort. + Terminate, +} diff --git a/desktop/ui/Cargo.toml b/desktop/ui/Cargo.toml new file mode 100644 index 0000000..1e08826 --- /dev/null +++ b/desktop/ui/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "blocker-ui" +version = "0.1.0" +edition = "2021" +description = "Blocker block-screen window. Launched on demand, exits when the block lifts." +license = "UNLICENSED" + +[lints] +workspace = true + +[[bin]] +name = "blocker-ui" +path = "src/main.rs" + +# NO UI TOOLKIT CHOSEN YET — deliberately. +# +# CLAUDE.md section 3 requires Slint vs egui to be decided on a measured +# size/RAM comparison, and explicitly says to ask the owner. That decision is +# still open (see docs/DECISIONS.md, ADR-6). +# +# Two constraints already rule out some options, for the record: +# - Slint is GPL-3.0 or commercial. For a project intended to be published, +# the permissive licences are the safer default. If sizes come out close, +# that should decide it. +# - Either way the daemon must not grow past 5 MB stripped (section 5), and +# the UI must not hold a GL context resident while idle. +[dependencies] \ No newline at end of file diff --git a/desktop/ui/src/main.rs b/desktop/ui/src/main.rs new file mode 100644 index 0000000..f79dfca --- /dev/null +++ b/desktop/ui/src/main.rs @@ -0,0 +1,27 @@ +//! Blocker block-screen UI. +//! +//! Placeholder. No UI toolkit is bound here yet — see ADR-6 in +//! `docs/DECISIONS.md` for why that is an open decision rather than an oversight. +//! +//! # What this process is for +//! +//! It is a short-lived, separately-executable window. The daemon stays resident +//! and small; this launches when a block fires and exits when it lifts. Keeping +//! them separate means a crash in window rendering cannot take enforcement down. +//! +//! # Design constraints that will apply (docs/UX-BLOCK-SCREEN.md) +//! +//! - Calm, not punitive. No red-alert styling, no shaming copy, no streak +//! counters. The screen exists to end an argument with the user's own +//! intention, so it must lower arousal. +//! - It always states which schedule blocked and when it lifts. Uncertainty is +//! what makes people fight the tool. +//! - It is never a dead end: a plain-looking "How to turn this off" entry is +//! always present, including under Strict Mode (CLAUDE.md section 1.5.4). +//! - Under Strict Mode the *absence* of a bypass button is stated explicitly. +//! Silently missing controls read as a bug and breed distrust. + +fn main() { + println!("blocker-ui {} (M0 placeholder)", env!("CARGO_PKG_VERSION")); + println!("UI toolkit undecided — ADR-6 in docs/DECISIONS.md"); +} diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..891ad0c --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,7 @@ +[toolchain] +# Pinned so a local build and a CI build are the same compiler. Section 10.4 +# requires every commit to build and pass tests on its own, which is only +# meaningful if the toolchain does not drift underneath it. +channel = "stable" +profile = "minimal" +components = ["rustfmt", "clippy"] \ No newline at end of file diff --git a/rustfmt.toml b/rustfmt.toml index 1a40a18..4f94ec1 100644 --- a/rustfmt.toml +++ b/rustfmt.toml @@ -1,12 +1,23 @@ edition = "2021" max_width = 100 -# Imports are grouped std / external / crate, and sorted within each group. -imports_granularity = "Crate" -group_imports = "StdExternalCrate" +# NOTE ON UNSTABLE OPTIONS +# +# `imports_granularity`, `group_imports` and `derive_debug` are nightly-only +# rustfmt features. rust-toolchain.toml pins stable, so listing them here emits +# a warning per crate per file and buys nothing. They are intentionally absent +# rather than commented out: an unrecognised key is a warning, a commented key +# is a trap for whoever re-enables nightly. +# +# If this project ever moves to nightly rustfmt, add: +# imports_granularity = "Crate" +# group_imports = "StdExternalCrate" +# derive_debug = false +# as a separate commit, so the diff is auditable. -# We write a lot of match arms over enums and error plumbing. Keep them tight. +# Trailing comma on match arms adds vertical noise in the evaluator, which is +# almost entirely match expressions. match_block_trailing_comma = false -# Derive Debug on virtually everything — engine output is logged in the field. -derive_debug = false +# Use small heuristics instead of the unstable ones. +use_small_heuristics = "Max" \ No newline at end of file diff --git a/tests/Cargo.toml b/tests/Cargo.toml new file mode 100644 index 0000000..3451894 --- /dev/null +++ b/tests/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "blocker-tests" +version = "0.1.0" +edition = "2021" +description = "Cross-platform scenario tests: golden decision tables that are the parity contract for every platform layer." +license = "UNLICENSED" + +[dependencies] +blocker-core = { path = "../core" } + +# NO serde HERE — YET. +# +# The golden tables are designed as JSON (see src/golden.rs), and JSON needs +# serde/serde_json. Those crates are deliberately NOT added in M0, for two +# reasons: +# +# 1. CLAUDE.md section 0 rule 3 requires stopping to ask before adding any +# dependency not listed in section 3. serde is not on that list. It is +# transitively required by rusqlite and uniffi (both listed), which is why +# the design assumed it would be present — but "will be present later" is +# not "approved to add now". +# +# 2. The development machine has 9.7 GB free. Fetching the registry is the +# step that actually consumes disk, and the dependency-purity test needs a +# Cargo.lock to exist at all. +# +# The format is specified in src/golden.rs as the contract M1 implements against. +# The parse layer arrives in M1 alongside rusqlite, in one dependency change +# with an ADR entry. See docs/DECISIONS.md ADR-3. + +[lints] +workspace = true + +[lib] +name = "blocker_tests" +path = "src/lib.rs" \ No newline at end of file diff --git a/tests/src/golden.rs b/tests/src/golden.rs new file mode 100644 index 0000000..73d6695 --- /dev/null +++ b/tests/src/golden.rs @@ -0,0 +1,242 @@ +//! The golden decision tables (CLAUDE.md section 7). +//! +//! A table is a list of cases. Each case is schedules plus an input plus an +//! expected decision, and running the same table against the engine must +//! reproduce the expectation exactly. +//! +//! # Format +//! +//! JSON, one file per concern, under `golden/`. JSON rather than TOML because +//! `serde_json` is already in the dependency tree via `rusqlite` and `uniffi` +//! (both listed in CLAUDE.md section 3), so TOML would add a crate for no gain. +//! +//! ```json +//! { +//! "concern": "overnight", +//! "cases": [ +//! { +//! "name": "mon 22:00-06:00 window still active at tue 03:00", +//! "covers": "7:overnight time ranges", +//! "schedules": [ +//! { "id": "s1", "name": "Night", "mode": "Blocklist", +//! "targets": [{"type": "app", "app": "com.example.social"}], +//! "conditions": [{"type": "Time", "days": [0], +//! "start": "22:00", "end": "06:00"}] } +//! ], +//! "input": { "civil_time": "2026-10-06T03:00:00+02:00" }, +//! "expect": { +//! "blocked_apps": [{"app": "com.example.social", "schedule": "s1"}], +//! "next_wakeup": "2026-10-06T06:00:00+02:00" +//! } +//! } +//! ] +//! } +//! ``` +//! +//! # Status in M0 +//! +//! The shapes below are the **contract**, written down before any parse layer +//! exists so M1 implements against a fixed format rather than retrofitting one. +//! `serde::Deserialize` is deliberately not derived yet — adding serde is a +//! dependency decision (CLAUDE.md section 0 rule 3) and is deferred to M1, +//! where it arrives with `rusqlite` under one ADR entry. +//! +//! # The clock-tampering exception +//! +//! Most cases are pure data. Clock tampering is not: proving that moving the +//! system date never shortens a lock requires actually moving a clock, which a +//! data file cannot express. Those cases live in [`crate::purity`] as code +//! fixtures instead, and the two coexist deliberately — the tables describe +//! decisions, the fixtures describe the passage of time. + +use std::collections::BTreeMap; + +/// One table, deserialised from one `golden/*.json` file. +#[derive(Debug, Clone)] +pub struct Table { + /// Which concern this table covers, e.g. `overnight`. + pub concern: String, + /// The cases in the table. + pub cases: Vec, +} + +/// One case: setup, input, expected decision. +#[derive(Debug, Clone)] +pub struct Case { + /// Human-readable, and the primary test failure message. + pub name: String, + /// Which CLAUDE.md section 7 scenario this covers, so coverage is auditable + /// rather than assumed. Checked against [`MANDATORY_COVERAGE`]. + pub covers: String, + /// The schedules under test. + pub schedules: Vec, + /// The input to the engine. + pub input: InputSpec, + /// The decision that must result. + pub expect: ExpectSpec, +} + +/// A schedule as it appears in a table. +#[derive(Debug, Clone)] +pub struct ScheduleSpec { + /// Stable id, referenced by `BlockedSpec::schedule`. + pub id: String, + /// Display name, shown on the block screen. + pub name: String, + /// `"Blocklist"` or `"Allowlist"`. + pub mode: String, + /// What this schedule targets. + pub targets: Vec, + /// Its conditions. At least one must hold for the schedule to be active. + pub conditions: Vec, + /// Defaults to false when absent from JSON. + pub paused: bool, +} + +/// A target, tagged by type so the JSON is self-describing. +#[derive(Debug, Clone)] +pub enum TargetSpec { + /// An app, by platform-native identifier. + App { + /// Package name on Android, desktop-file id or exe path elsewhere. + app: String, + }, + /// A registrable domain. Subdomains are implied. + Website { + /// e.g. `example.com`. + website: String, + }, + /// A case-insensitive substring matched against the URL. + Keyword { + /// The substring to match. + keyword: String, + }, +} + +/// A condition, tagged by type. +#[derive(Debug, Clone)] +pub enum ConditionSpec { + /// Block while local time is inside the window. + Time { + /// Weekdays on which the window **starts**. Monday = 0, matching + /// `time::Weekday`. + days: Vec, + /// `HH:MM`. When `start > end` the window spans midnight — there is + /// deliberately no `crosses_midnight` flag, because the comparison + /// already encodes it and a second field is a second way to be wrong. + start: String, + /// `HH:MM`. See `start`. + end: String, + }, + /// Block after a foreground-time threshold. + UsageLimit { + /// Threshold in minutes. + limit_minutes: u32, + /// `"Daily"` or `"Hourly"`. + window: String, + }, + /// Block after a launch-count threshold. + LaunchCount { + /// Threshold count. + limit: u32, + /// `"Daily"` or `"Hourly"`. + window: String, + }, + /// Block while connected to a named network. + WifiSsid { + /// The SSID to match exactly. + ssid: String, + }, + /// Block inside or outside a radius. Mobile only (section 6.4). + Location { + /// Circle centre latitude in degrees. + latitude: f64, + /// Circle centre longitude in degrees. + longitude: f64, + /// Radius in metres. + radius_meters: u32, + /// Invert the test: block when *outside* the radius. Defaults false. + block_when_outside: bool, + }, +} + +/// The engine input for a case. +#[derive(Debug, Clone, Default)] +pub struct InputSpec { + /// RFC 3339 with an explicit offset, e.g. `2026-10-06T03:00:00+02:00`. + /// The offset is mandatory so a DST case is unambiguous. + pub civil_time: String, + /// Monotonic milliseconds since boot. Absent means 0. + pub monotonic_ms: u64, + /// Boot id. Absent means 0. + pub boot_id: u64, + /// Accumulated usage. + pub usage: UsageSpec, + /// Platform-observed context. + pub context: ContextSpec, +} + +/// Usage counters for a case. All fields default to empty. +#[derive(Debug, Clone, Default)] +pub struct UsageSpec { + /// Foreground minutes per target, daily bucket. + pub daily_minutes: BTreeMap, + /// Foreground minutes per target, hourly bucket. + pub hourly_minutes: BTreeMap, + /// Launch counts per target, daily bucket. + pub daily_launches: BTreeMap, + /// Launch counts per target, hourly bucket. + pub hourly_launches: BTreeMap, +} + +/// Platform context for a case. +#[derive(Debug, Clone, Default)] +pub struct ContextSpec { + /// Current Wi-Fi SSID, if any. + pub current_ssid: Option, + /// Current `(latitude, longitude)`, if any. + pub current_location: Option<(f64, f64)>, +} + +/// The decision a case expects. +#[derive(Debug, Clone)] +pub struct ExpectSpec { + /// Apps that must be blocked, each attributed to a schedule. + pub blocked_apps: Vec, + /// Websites and keywords that must be blocked. + pub blocked_domains: Vec, + /// RFC 3339, or the literal `none` when the platform may sleep forever. + pub next_wakeup: String, +} + +/// One expected blocked item. +#[derive(Debug, Clone)] +pub struct BlockedSpec { + /// The target that must be blocked. + pub app: String, + /// The id of the schedule that must block it. + pub schedule: String, +} + +/// Every scenario in [`MANDATORY_COVERAGE`] must be claimed by at least one +/// `covers` field across the tables, and `coverage.rs` asserts it — so a +/// scenario cannot silently lose its test. +pub const MANDATORY_COVERAGE: &[&str] = &[ + "7:overnight time ranges", + "7:DST changes", + "7:timezone changes", + "7:clock tampering", + "7:strictest-wins", + "7:allowlist mode", + "7:invalid condition combos", + "7:strict mode lock rules", + // The section 1.2 combination table, itemised so a missing pair is visible. + "1.2:time+usage rejected", + "1.2:time+launch rejected", + "1.2:usage+launch rejected", + "1.2:usage+location rejected", + "1.2:launch+location rejected", + // Section 1.5.4 safety guarantees. + "1.5.4:never-block list is unblockable", + "1.5.4:worst case 24h", +]; diff --git a/tests/src/lib.rs b/tests/src/lib.rs new file mode 100644 index 0000000..027bd88 --- /dev/null +++ b/tests/src/lib.rs @@ -0,0 +1,37 @@ +//! Cross-platform scenario tests (CLAUDE.md section 7). +//! +//! This crate holds the **golden decision tables**: declarative schedules, +//! inputs, and expected decisions that are run against the core engine. +//! +//! # Why this is a contract, not a test suite +//! +//! Section 7 requires that the same table runs against the core and serves as +//! the parity contract for all platforms. Android, Linux and Windows each +//! implement a mapping from a platform app identity to a `Target`, but they all +//! hand the same [`blocker_core`] engine the same schedule set and get back the +//! same decision. A platform crate runs this table against its own mapping, so +//! if Linux disagrees with Android the build fails rather than the user filing +//! a bug. +//! +//! # The rule that keeps it honest +//! +//! **A case is added before the engine changes.** If a table were written after +//! the code, it would only record current behaviour and could never catch a +//! regression — it would be a transcript, not a specification. Reviewing a PR +//! that changes engine behaviour therefore means checking that a case was added +//! or updated in the same commit. +//! +//! # Layout +//! +//! - `golden/` — the tables themselves, one JSON file per concern +//! - [`purity`] — a mechanical check that the core has not gained I/O +//! +//! The mandatory case list is in [`golden::MANDATORY_COVERAGE`]. +//! +//! [`purity`] is `cfg(test)` because its checks are `#[test]` functions in this +//! library's unit-test build, not library API. They run under `cargo test`. + +pub mod golden; + +#[cfg(test)] +mod purity; diff --git a/tests/src/purity.rs b/tests/src/purity.rs new file mode 100644 index 0000000..3f00834 --- /dev/null +++ b/tests/src/purity.rs @@ -0,0 +1,261 @@ +//! Mechanical proof of the engine purity contract (CLAUDE.md section 2). +//! +//! The engine must be a pure function of its inputs — no clock reads, no I/O, +//! no OS calls. That rule is what guarantees Android, Linux and Windows reach +//! identical decisions, and therefore what makes the golden tables a parity +//! contract rather than a wish. +//! +//! # Why this is a test and not a code review +//! +//! Purity is easy to break by accident and hard to notice. A `SystemTime::now()` +//! added to a helper looks harmless, and the platform it breaks is whichever one +//! nobody is testing that day. A mechanical check fails loudly at build time +//! instead. `dependency_purity` below reads the *resolved* dependency graph, so +//! it cannot be fooled by a transitive addition either. +//! +//! # Why `expect` appears here and nowhere else +//! +//! The workspace denies `unwrap_used` and `expect_used` outright (CLAUDE.md +//! section 0 rule 5: no `unwrap` on fallible OS or I/O results). Those lints are +//! right for shipping code and wrong for test fixtures, where a missing +//! `Cargo.lock` or an absent `NEVER_BLOCK` entry means the test itself is +//! broken and there is nothing to handle gracefully. The `allow` below is scoped +//! to this module, with the reason stated, so the exemption is visible in review +//! rather than being a workspace-wide hole. + +#![cfg(test)] +#![allow( + clippy::unwrap_used, + clippy::expect_used, + reason = "test fixtures: a missing file or empty list is a broken test, not a runtime condition" +)] +//! # The clock-tampering fixtures live here +//! +//! Section 1.5.3 requires that changing the system date can never shorten a +//! lock. Proving that needs a clock that moves, which a JSON table cannot +//! express, so these are code fixtures rather than data cases. Both kinds +//! coexist deliberately: the tables describe *decisions*, this module describes +//! *the passage of time*. + +use blocker_core::engine::{BootId, CivilTime, Monotonic}; +use blocker_core::lock::{self, LockLevel}; + +/// The core crate must have no dependencies at all. +/// +/// This is the strongest form of the check. The core currently needs nothing +/// from the ecosystem — time arithmetic is done on `i64` minutes — so any +/// dependency appearing here is either a bug (something I/O reached in) or a +/// decision that belongs in a documented ADR rather than a silent addition. +#[test] +fn dependency_purity() { + let cargo_lock = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/../Cargo.lock")) + .expect("Cargo.lock is generated by any cargo build"); + + let blocker_core = cargo_lock + .split("[[package]]") + .find(|block| block.contains("name = \"blocker-core\"")) + .expect("blocker-core must appear in Cargo.lock"); + + let deps: Vec<&str> = blocker_core + .lines() + .skip_while(|line| !line.contains("dependencies = [")) + .skip(1) + .take_while(|line| !line.trim_start().starts_with(']')) + .map(|line| line.trim().trim_matches('"')) + .collect(); + + assert!( + deps.is_empty(), + "blocker-core must have zero dependencies to stay pure, found: {deps:?}\n\ + A core dependency is either an I/O reach-in that breaks cross-platform \ + parity, or an undocumented crate. Justify it in docs/DECISIONS.md first." + ); +} + +/// Moving the system clock backwards must not shorten a lock. +/// +/// The concrete attack: a 12-hour time-lock request is running, and the user +/// sets the system date back three days hoping the request expires instantly. +#[test] +fn backwards_wall_clock_never_shortens_a_lock() { + let deadline = Monotonic(12 * 3_600_000); // 12h of monotonic time left + + // Wall clock observed three days *behind* what elapsed time predicts. + let observed = CivilTime { epoch_minutes: 0, utc_offset_minutes: 0 }; + let expected = CivilTime { epoch_minutes: 3 * 24 * 60, utc_offset_minutes: 0 }; + + let correction = lock::wall_clock_correction(observed, expected, Monotonic(0)) + .expect("a three-day jump must be detected"); + assert!( + correction < 0, + "an observed clock behind expectation is reported as a negative correction" + ); + + let adjusted = lock::apply_correction(deadline, correction); + assert!( + adjusted.0 >= deadline.0, + "a backwards wall-clock jump must not shorten the lock: \ + 12h deadline became {}ms", + adjusted.0 + ); +} + +/// A forward jump must not shorten the lock either, and neither may push it past +/// the 24-hour cap — extending without a bound would violate section 1.5.4 even +/// though it satisfies the anti-tamper rule. +#[test] +fn wall_clock_correction_respects_both_bounds() { + let deadline = Monotonic(12 * 3_600_000); + + // Forward jump of three days. Still must not shorten. + let forward = lock::apply_correction(deadline, 3 * 24 * 60); + assert!(forward.0 >= deadline.0, "a forward jump must never shorten a lock either"); + + // But the result is clamped: a 3-day rollback must not turn a 12h lock into + // a 3-day wait (section 1.5.4 caps the worst case at 24h). + assert!( + forward.0 <= lock::MAX_WAIT_MS, + "the corrected deadline {}ms exceeds the 24h cap {}ms", + forward.0, + lock::MAX_WAIT_MS + ); + assert_eq!(forward.0, lock::MAX_WAIT_MS, "12h + 3 days clamps to the cap"); + + // A zero correction leaves the deadline alone. + assert_eq!(lock::apply_correction(deadline, 0), deadline); +} + +/// No detected jump means no correction at all — an honest clock must not cost +/// the user any extra waiting. +#[test] +fn an_undisturbed_clock_costs_nothing() { + let t = CivilTime { epoch_minutes: 1_000_000, utc_offset_minutes: 60 }; + assert_eq!( + lock::wall_clock_correction(t, t, Monotonic(5_000)), + None, + "an unchanged clock must report no correction" + ); +} + +/// A reboot must not reset a lock. +#[test] +fn reboot_resumes_conservatively() { + // 5h of monotonic time had elapsed before the reboot; the lock had 3h left. + let deadline_before_reboot = Monotonic(12 * 3_600_000); + let elapsed_before_reboot = Monotonic(5 * 3_600_000); + + let resumed = lock::resume_after_reboot(deadline_before_reboot, elapsed_before_reboot); + assert!( + resumed >= deadline_before_reboot || resumed >= elapsed_before_reboot, + "resume must take the later of the two, never the earlier" + ); + assert_eq!( + resumed.0, deadline_before_reboot.0, + "with 7h elapsed and 12h set, the remaining 7h is still owed after reboot" + ); +} + +/// Everything on the never-block list is unblockable, and ordinary apps are not +/// swept up by the substring match. +/// +/// The list is per-platform (CLAUDE.md section 1.5.4 names phone/dialer and +/// emergency paths on mobile, and the OS shell plus Settings basics on +/// desktop), so the test iterates the list this build actually has rather than +/// assuming a global one. +#[test] +fn never_block_list_is_unblockable() { + assert!( + !blocker_core::never_block::NEVER_BLOCK.is_empty(), + "every supported platform must protect something; an empty list would mean \ + a device could be locked out of its own dialler or shell" + ); + + for protected in blocker_core::never_block::NEVER_BLOCK { + assert!( + blocker_core::never_block::is_never_blocked(protected), + "{protected} is on the list and must therefore be unblockable" + ); + } + + // Substring matching must not over-match into unrelated apps. + for ordinary in ["com.example.social", "org.example.chat", "steam"] { + assert!( + !blocker_core::never_block::is_never_blocked(ordinary), + "{ordinary} is an ordinary app and must remain blockable" + ); + } +} + +/// The never-block check is consulted inside the engine, so a schedule naming a +/// protected app cannot produce a block entry for it. +#[test] +fn engine_cannot_block_a_never_blocked_app() { + use blocker_core::engine::{CivilTime, EngineInput, Monotonic, PlatformContext, UsageSnapshot}; + use blocker_core::model::{Condition, Schedule, ScheduleMode, Target, TimeWindow}; + + // On this platform the list contains the desktop shell and Settings. Pick + // the first entry that actually exists for this build. + let protected = + blocker_core::never_block::NEVER_BLOCK.first().expect("non-empty list").to_string(); + + let schedules = vec![Schedule { + id: "s1".into(), + name: "Attempts to block the shell".into(), + icon: None, + mode: ScheduleMode::Blocklist, + targets: vec![Target::App(protected.clone())], + conditions: vec![Condition::Time(TimeWindow { + days: vec![0, 1, 2, 3, 4, 5, 6], + start: 0, + end: 1439, + })], + paused: false, + }]; + + let usage = UsageSnapshot::default(); + let context = PlatformContext::default(); + let input = EngineInput { + now: CivilTime { epoch_minutes: 0, utc_offset_minutes: 0 }, + monotonic: Monotonic(0), + boot_id: BootId(0), + schedules: &schedules, + usage: &usage, + context: &context, + installed_apps: &[], + }; + + let decision = blocker_core::evaluate(&input); + assert!( + decision.blocked_apps.is_empty(), + "the engine produced a block for {protected}, which is on the never-block \ + list and must be structurally unblockable (section 1.5.4)" + ); +} + +/// The worst-case disable path is capped at 24 hours. +#[test] +fn worst_case_disable_path_is_capped() { + for level in [LockLevel::Normal, LockLevel::Strict, LockLevel::Stubborn] { + let state = blocker_core::lock::LockState { + level, + schedules_locked: false, + pending: None, + cooldown_remaining: None, + attempts_today: 0, + keyholder_code: None, + }; + assert!( + state.worst_case_wait() <= lock::MAX_WAIT_MS, + "{level:?} worst case {}ms exceeds the 24h cap", + state.worst_case_wait() + ); + } + assert_eq!(lock::MAX_WAIT_MS, 24 * 3_600_000, "the cap is exactly 24 hours"); +} + +/// A boot id change is distinguishable from a daemon restart. +#[test] +fn boot_id_identifies_a_reboot() { + let first = BootId(0xDEAD_BEEF); + assert_ne!(first, BootId(0xDEAD_BEEE), "a reboot yields a different boot id"); +}