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

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

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

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

Decisions worth calling out:

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

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

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
2026-10-01 15:02:20 +03:00
parent aa1c582f3c
commit c723efb186
25 changed files with 2794 additions and 31 deletions
Generated
+39
View File
@@ -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"
+65
View File
@@ -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
+23 -25
View File
@@ -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"
+19
View File
@@ -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"
+836
View File
@@ -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<String>,
/// 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<String, u32>,
/// Foreground minutes per app, in the current hourly bucket.
pub hourly_minutes: std::collections::BTreeMap<String, u32>,
/// Launch counts per app in the current daily bucket.
pub daily_launches: std::collections::BTreeMap<String, u32>,
/// Launch counts per app in the current hourly bucket.
pub hourly_launches: std::collections::BTreeMap<String, u32>,
}
/// 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<BlockReason>,
/// Websites and keyword matches currently blocked.
pub blocked_domains: Vec<BlockReason>,
/// 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<CivilTime>,
}
/// 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<BlockReason> = Vec::new();
let mut blocked_domains: Vec<BlockReason> = Vec::new();
let mut next_wakeup: Option<CivilTime> = 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<String, u32>,
) -> 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<CivilTime> {
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<CivilTime> {
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<CivilTime> {
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<i64> = 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<CivilTime> {
schedule
.conditions
.iter()
.filter_map(|c| condition_boundary(c, input))
.min_by_key(|t| t.epoch_minutes)
}
fn earlier(a: Option<CivilTime>, b: CivilTime) -> Option<CivilTime> {
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<DayOfWeek>, 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));
}
}
+63
View File
@@ -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
}
+276
View File
@@ -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<PendingRequest>,
/// Cooldown on the disable screen. Leaving the screen resets it.
pub cooldown_remaining: Option<Monotonic>,
/// 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<String>,
}
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<Monotonic>, 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<Monotonic>, 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<i64> {
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))
}
+264
View File
@@ -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<DayOfWeek>,
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<String>,
pub mode: ScheduleMode,
pub targets: Vec<Target>,
/// A schedule is active when at least one condition holds (section 1.2).
pub conditions: Vec<Condition>,
/// 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<crate::engine::CivilTime>,
}
/// 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<ValidationError>> {
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",
}
}
+65
View File
@@ -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()))
}
+92
View File
@@ -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";
+131
View File
@@ -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<String, u32>,
/// Foreground minutes per target in the current hourly bucket.
pub hourly_minutes: std::collections::BTreeMap<String, u32>,
/// Launch counts per target in the current daily bucket.
pub daily_launches: std::collections::BTreeMap<String, u32>,
/// Launch counts per target in the current hourly bucket.
pub hourly_launches: std::collections::BTreeMap<String, u32>,
/// Monotonic timestamp of the current bucket boundaries, used to decide
/// when to rotate.
pub bucket_started_at: Option<u64>,
}
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<u64>, 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<String, u32>, key: &str) {
let entry = map.entry(key.to_owned()).or_insert(0);
*entry = entry.saturating_add(1);
}
fn accumulate(map: &mut std::collections::BTreeMap<String, u32>, key: &str, minutes: u32) {
let entry = map.entry(key.to_owned()).or_insert(0);
*entry = entry.saturating_add(minutes);
}
+19
View File
@@ -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" }
+33
View File
@@ -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");
}
+16
View File
@@ -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" }
+115
View File
@@ -0,0 +1,115 @@
//! Linux platform layer.
//!
//! Observes the OS and enforces decisions. Contains **no decision logic**
//! (CLAUDE.md section 2).
//!
//! # The trait this crate exists to implement
//!
//! Linux has no portable way to ask "which window has focus" under Wayland.
//! The [`ForegroundWatcher`] trait below is the seam, and it will have three
//! implementations, tried in order at startup:
//!
//! 1. **`KwinBackend`** — the primary path on this machine. Loads a small
//! KWin script over the `org.kde.KWin /Scripting` D-Bus interface, which
//! hooks `windowActivated` and reports back to the daemon. Verified
//! available on KDE Plasma 6.6.6: `loadScript`, `start`, `unloadScript`,
//! `isScriptLoaded` are all present.
//!
//! 2. **`X11Backend`** — the portable fallback. `x11rb` subscribes to
//! `_NET_ACTIVE_WINDOW` property changes, resolves `_NET_WM_PID`, then reads
//! `/proc/<pid>/exe`. Works on any X11 session and is testable headless with
//! Xvfb, so this is also the path CI exercises.
//!
//! 3. **`ProcessBackend`** — a degraded last resort with no idea what has focus,
//! which acts on process creation instead. It cannot show a meaningful block
//! screen and should never be reached on a working KDE session.
//!
//! # Honest limits (CLAUDE.md section 6)
//!
//! - `X11Backend` is **blind to native Wayland apps** under a Wayland session.
//! Under XWayland it sees only XWayland clients. This is why it is a fallback
//! and not the primary.
//! - `/proc/connectors` does not exist on the development kernel, so the
//! netlink proc-connector process watcher is unavailable and
//! `ProcessBackend` must use eBPF or `/proc` polling — a worse experience
//! than a block screen, by construction.
//! - The KWin backend is KDE-specific. wlroots
//! (`foreign-toplevel-management`) and GNOME backends remain in M7 per
//! section 8, because no portable API exists to build them against.
//! - None of this can be made tamper-proof against a user with root. Section
//! 6.1 requires this be documented honestly rather than implied otherwise.
/// A window that has taken focus, resolved to something a schedule can match.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ForegroundWindow {
/// Backend-native window handle. KWin gives a `uuid` string; X11 gives an
/// XID. Kept as a string so the trait is not tied to one backend.
pub handle: String,
/// Resolved process id, where the backend can supply one.
pub pid: Option<u32>,
/// Absolute path of the executable, read from `/proc/<pid>/exe`.
pub exe_path: Option<String>,
/// Window title, for display on the block screen.
pub title: Option<String>,
/// Desktop-file id or WM_CLASS, which is what a Linux user actually
/// recognises as "the app" and what schedules should match against.
pub desktop_id: Option<String>,
}
/// Events a watcher can raise. The daemon blocks on this channel; there is no
/// polling (section 2).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FocusEvent {
/// A window gained focus.
Activated(ForegroundWindow),
/// The focused window closed, so there is no longer a foreground window.
Cleared,
}
/// Source of focus events.
///
/// Implemented once per backend. M2-Linux fills these in.
pub trait ForegroundWatcher {
/// Begin delivering events to `sink`.
///
/// Implementations must be event-driven: they subscribe to an X11 property
/// change or a compositor callback, never to a timer. An implementation
/// that polls would break the section 5 idle budget even if it functioned.
fn subscribe(&mut self, sink: std::sync::mpsc::Sender<FocusEvent>);
/// Which backend this is, for the troubleshooting screen and the log.
fn name(&self) -> &'static str;
/// Why this backend was chosen, in plain language for the user.
fn selection_reason(&self) -> &'static str;
}
/// Enforcement actions, in escalating order and configurable by the user
/// (CLAUDE.md section 4.3).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Enforcement {
/// Show the block screen. The default, and the only non-destructive option.
BlockScreen,
/// Ask the compositor to minimise the window.
Minimise,
/// `SIGSTOP` the process. Freezes the app rather than closing it, so the
/// user's state is preserved and the resume is honest.
Freeze,
/// Terminate the process. Destructive, and the last resort.
Terminate,
}
/// What the platform layer may and may not do unprivileged.
///
/// Recorded here because section 0 rule 2 requires honesty rather than a stub
/// that pretends. Each of these is a real limit of an unprivileged user
/// daemon, not a missing feature.
pub const UNPRIVILEGED_LIMITS: &[&str] = &[
"cannot prevent a process from launching, only react to it after the fact",
"cannot mask a systemd unit (that needs root); tamper resistance is \
Restart=always plus a watchdog, nothing stronger",
"cannot make its own config tamper-proof against the same user, who owns the \
files — lock state lives in the DB instead (section 1.5.3)",
"cannot read /proc/<pid>/exe for processes owned by other users",
"cannot block a process that is already running as root",
];
+20
View File
@@ -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" }
+64
View File
@@ -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<u32>,
pub exe_path: Option<String>,
pub title: Option<String>,
/// UWP package family name, where the app is packaged.
pub package_identity: Option<String>,
}
/// 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<FocusEvent>);
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,
}
+27
View File
@@ -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]
+27
View File
@@ -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");
}
+7
View File
@@ -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"]
+17 -6
View File
@@ -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"
+36
View File
@@ -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"
+242
View File
@@ -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<Case>,
}
/// 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<ScheduleSpec>,
/// 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<TargetSpec>,
/// Its conditions. At least one must hold for the schedule to be active.
pub conditions: Vec<ConditionSpec>,
/// 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<u8>,
/// `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<String, u32>,
/// Foreground minutes per target, hourly bucket.
pub hourly_minutes: BTreeMap<String, u32>,
/// Launch counts per target, daily bucket.
pub daily_launches: BTreeMap<String, u32>,
/// Launch counts per target, hourly bucket.
pub hourly_launches: BTreeMap<String, u32>,
}
/// Platform context for a case.
#[derive(Debug, Clone, Default)]
pub struct ContextSpec {
/// Current Wi-Fi SSID, if any.
pub current_ssid: Option<String>,
/// 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<BlockedSpec>,
/// Websites and keywords that must be blocked.
pub blocked_domains: Vec<BlockedSpec>,
/// 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",
];
+37
View File
@@ -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;
+261
View File
@@ -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");
}