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:
Generated
+39
@@ -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
@@ -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
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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" }
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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" }
|
||||
@@ -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",
|
||||
];
|
||||
@@ -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" }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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]
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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",
|
||||
];
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user