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

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

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

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

Decisions worth calling out:

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

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

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
2026-10-01 15:02:20 +03:00
parent 440b4748f2
commit 585fd30440
25 changed files with 2794 additions and 31 deletions
+836
View File
@@ -0,0 +1,836 @@
//! The evaluator: schedules plus context in, a decision out.
//!
//! The signature is the whole design. [`evaluate`] takes everything it could
//! possibly need and returns everything the platform layer needs to act. It
//! reads no clock, touches no disk, and calls no OS API — see the crate-level
//! purity contract.
//!
//! ## Why `next_wakeup` exists
//!
//! Section 2 requires the engine to be event-driven and never polling. The
//! mechanism is this field: the platform layer sleeps until `next_wakeup` or
//! until an OS event arrives, whichever is first. `None` means no schedule can
//! change on its own, so the platform sleeps indefinitely.
//!
//! The consequence for the section 5 budget is the important part: an idle
//! system takes **zero** wakeups, because nothing is scheduled to expire. A
//! timer that fires to discover that nothing changed is a wakeup, and a project
//! with an explicit zero-wakeup budget cannot afford many.
use crate::model::{BlockReason, Condition, MinuteOfDay, Schedule, ScheduleMode, Target};
/// Wall-clock time as seen by a user: a civil date and time in some timezone.
///
/// Used for schedule windows, where "09:00 means nine in the morning" is the
/// correct behaviour — the same schedule should stay 09:00 across a DST
/// boundary. Never used for lock arithmetic; that is [`Monotonic`]'s job.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct CivilTime {
/// Minutes since 1970-01-01T00:00:00Z, plus the local UTC offset in
/// minutes. Storing an offset rather than a resolved zone keeps the type
/// comparable and serialisable without pulling in a full tz database at
/// this layer.
pub epoch_minutes: i64,
/// Local UTC offset in minutes. Kept alongside the epoch value rather than
/// resolved into a timezone, because a schedule means "09:00 in the user's
/// own time" and must stay 09:00 across a DST change.
pub utc_offset_minutes: i32,
}
/// Milliseconds elapsed since boot, from a monotonic clock.
///
/// The value wraps nothing and never goes backwards, which is precisely what
/// makes it safe for locks. Section 1.5.3 requires every lock timer to be
/// monotonic so that changing the system date can never shorten a delay, and
/// keeping this a separate type from [`CivilTime`] makes the distinction hard
/// to lose: `evaluate` receives both, and only the monotonic field participates
/// in lock arithmetic.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct Monotonic(
/// Elapsed milliseconds since boot. Saturating arithmetic is used on every
/// operation that could otherwise wrap.
pub u64,
);
/// Identifies one boot of one machine.
///
/// Lets the lock subsystem tell "the daemon restarted" from "the machine
/// rebooted" (section 1.5.3), so timer state is resumed conservatively rather
/// than assumed to have continued.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct BootId(pub u64);
/// Platform-supplied context the engine cannot observe for itself.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct PlatformContext {
/// Current Wi-Fi SSID, if the platform can supply one.
pub current_ssid: Option<String>,
/// Current location, if the platform is Android and permission is granted.
pub current_location: Option<(f64, f64)>,
/// Section 1.7: what to do when location is unavailable.
pub location_unavailable_behavior: LocationUnavailable,
}
/// Section 1.7 setting for the case where a location condition exists but no
/// fix is available.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum LocationUnavailable {
/// Treat an unavailable location as "not at the location", so the schedule
/// does not block. Safe default: failing open means a glitch never blocks
/// the user, which is what section 1.5.4 requires.
#[default]
DoNotBlock,
/// Treat an unavailable location as "at the location" and block. Useful
/// when the point of the schedule is to block *leaving* somewhere.
Block,
}
/// Accumulated usage for every tracked target.
///
/// Counts are keyed by the same identifier as [`Target`], so lookup during
/// evaluation is a map hit rather than a scan (section 5 — this is the hot
/// path for usage-limit schedules).
#[derive(Debug, Clone, Default, PartialEq)]
pub struct UsageSnapshot {
/// Foreground minutes per app, in the current daily bucket.
pub daily_minutes: std::collections::BTreeMap<String, u32>,
/// Foreground minutes per app, in the current hourly bucket.
pub hourly_minutes: std::collections::BTreeMap<String, u32>,
/// Launch counts per app in the current daily bucket.
pub daily_launches: std::collections::BTreeMap<String, u32>,
/// Launch counts per app in the current hourly bucket.
pub hourly_launches: std::collections::BTreeMap<String, u32>,
}
/// Everything `evaluate` may read.
#[derive(Debug, Clone, PartialEq)]
pub struct EngineInput<'a> {
/// Wall clock, for schedule windows only.
pub now: CivilTime,
/// Monotonic time, for lock arithmetic only.
pub monotonic: Monotonic,
/// Which boot this is, for reboot detection.
pub boot_id: BootId,
/// Every configured schedule, including paused ones — `evaluate` decides
/// what to skip.
pub schedules: &'a [Schedule],
/// Accumulated usage for the current buckets.
pub usage: &'a UsageSnapshot,
/// What the platform could observe.
pub context: &'a PlatformContext,
/// Apps that exist on this machine, as the platform layer sees them.
///
/// Needed only by allowlist schedules, which block the complement of their
/// own list (section 1.1) and so cannot be evaluated without knowing what
/// exists. Blocklist schedules ignore it. An empty slice is not an error: it
/// means "we could not enumerate", and the engine then blocks nothing
/// rather than guessing — the fail-open rule of section 1.5.4.
pub installed_apps: &'a [String],
}
/// Everything `evaluate` produces.
#[derive(Debug, Clone, PartialEq)]
pub struct Decision {
/// Apps currently blocked. Union over all active schedules — this is where
/// strictest-wins (section 1.1) falls out for free: an app is blocked if
/// at least one schedule wants it blocked.
pub blocked_apps: Vec<BlockReason>,
/// Websites and keyword matches currently blocked.
pub blocked_domains: Vec<BlockReason>,
/// Lock state for the friction ladder (sections 1.4 and 1.5).
pub lock: crate::lock::LockState,
/// The earliest moment any decision could change, or `None` if nothing is
/// time-dependent right now.
///
/// This is the anti-polling contract. `None` means the platform layer may
/// sleep until an OS event arrives, with no timer at all.
pub next_wakeup: Option<CivilTime>,
}
/// Evaluate the current state into a decision.
///
/// Pure: no clock read, no I/O, no OS call. Same inputs always produce the same
/// `Decision`, which is what makes the golden tables in `tests/` a parity
/// contract across platforms.
pub fn evaluate(input: &EngineInput<'_>) -> Decision {
let mut blocked_apps: Vec<BlockReason> = Vec::new();
let mut blocked_domains: Vec<BlockReason> = Vec::new();
let mut next_wakeup: Option<CivilTime> = None;
// Allowlist schedules block everything NOT in their list (section 1.1), so
// they cannot be evaluated target-by-target like a blocklist — they need
// the set of apps that actually exist on the machine. Collected first so the
// complement is computed against every active allowlist at once. Owned, not
// borrowed, so nothing is leaked.
let mut permitted: Vec<&str> = Vec::new();
for schedule in input.schedules {
if schedule.paused || !matches!(schedule.mode, ScheduleMode::Allowlist) {
continue;
}
if active_conditions(schedule, input).is_empty() {
continue;
}
for target in &schedule.targets {
// Section 1.1: allowlist mode is apps-only. Validation rejects a
// website target in an allowlist, so that arm is unreachable for a
// valid schedule.
if let Target::App(id) = target {
permitted.push(id.as_str());
}
}
}
let has_allowlist = !permitted.is_empty();
// Arm the wakeup BEFORE the activity filter below, and over EVERY
// non-paused schedule rather than only currently-active ones.
//
// This ordering is load-bearing. A schedule that is inactive now but starts
// at 09:00 Monday must still arm a wakeup for 09:00 Monday — if only active
// conditions contributed, the daemon would sleep straight through the moment
// it is supposed to start blocking, and the schedule would silently never
// fire. Computing this before `continue` is what makes "no timer unless a
// decision can change" and "never miss a change" both true.
for schedule in input.schedules {
if schedule.paused {
continue;
}
if let Some(boundary) = next_transition(schedule, input) {
next_wakeup = earlier(next_wakeup, boundary);
}
}
for schedule in input.schedules {
if schedule.paused {
continue;
}
let active = active_conditions(schedule, input);
if active.is_empty() {
continue;
}
let ends_at = next_boundary(schedule, input);
match schedule.mode {
ScheduleMode::Blocklist => {
for target in &schedule.targets {
let reason = BlockReason {
schedule_id: schedule.id.clone(),
schedule_name: schedule.name.clone(),
condition: active[0],
ends_at,
};
match target {
// Section 1.5.4: the never-block check happens here,
// after evaluation, and nothing can reverse it.
Target::App(id) if !crate::never_block::is_never_blocked(id) => {
blocked_apps.push(reason);
}
Target::Website(_) | Target::Keyword(_) => {
blocked_domains.push(reason);
}
Target::App(_) => {}
}
}
}
ScheduleMode::Allowlist => {
// The complement, over the installed-app inventory the platform
// layer supplied. An empty inventory blocks nothing, which is the
// fail-open outcome (section 1.5.4): if we cannot see what is
// installed, we do not guess.
for id in input.installed_apps {
if !has_allowlist
|| permitted.contains(&id.as_str())
|| crate::never_block::is_never_blocked(id)
{
continue;
}
blocked_apps.push(BlockReason {
schedule_id: schedule.id.clone(),
schedule_name: schedule.name.clone(),
condition: active[0],
ends_at,
});
}
}
}
}
Decision {
blocked_apps,
blocked_domains,
lock: crate::lock::LockState::evaluate(input),
next_wakeup,
}
}
/// Which conditions of a schedule currently hold. A schedule is active when at
/// least one does (section 1.2).
fn active_conditions(schedule: &Schedule, input: &EngineInput<'_>) -> Vec<&'static str> {
schedule
.conditions
.iter()
.filter(|c| condition_holds(c, input))
.map(crate::model::condition_name)
.collect()
}
fn condition_holds(condition: &Condition, input: &EngineInput<'_>) -> bool {
match condition {
Condition::Time(window) => time_window_contains(window, input.now),
Condition::UsageLimit { limit_minutes, window } => {
let used = usage_for(input, window, |u| &u.daily_minutes);
used >= u64::from(*limit_minutes)
}
Condition::LaunchCount { limit, window } => {
let used = usage_for(input, window, |u| &u.daily_launches);
used >= u64::from(*limit)
}
Condition::WifiSsid(ssid) => input.context.current_ssid.as_deref() == Some(ssid.as_str()),
Condition::Location { latitude, longitude, radius_meters, block_when_outside } => {
match input.context.current_location {
None => matches!(
input.context.location_unavailable_behavior,
LocationUnavailable::Block
),
Some((lat, lon)) => {
let inside = haversine_meters(*latitude, *longitude, lat, lon)
<= f64::from(*radius_meters);
inside != *block_when_outside
}
}
}
}
}
fn usage_for<'a>(
input: &'a EngineInput<'a>,
window: &crate::model::UsageWindow,
pick: impl Fn(&'a UsageSnapshot) -> &std::collections::BTreeMap<String, u32>,
) -> u64 {
let map = pick(input.usage);
// A usage condition applies to the schedule's own targets, so the sum is
// over all tracked apps; M1 narrows this to the schedule's target set once
// targets carry usage keys.
let _ = window;
map.values().map(|v| u64::from(*v)).sum()
}
/// The heart of the overnight problem (section 1.2).
///
/// `start > end` means the window wraps past midnight. When it does, the
/// window opened on the *previous* day, so membership at 03:00 Tuesday must be
/// decided by Monday's weekday. The naive "is now inside today's window" check
/// gets this wrong, and a nightly 22:00–06:00 schedule that stops blocking at
/// midnight is exactly the bug users notice first.
pub fn time_window_contains(window: &crate::model::TimeWindow, now: CivilTime) -> bool {
let minute_of_day = minute_of_day(now);
let today = weekday(now);
if window.is_overnight() {
// Early part of an overnight window: it started yesterday, so yesterday's
// weekday governs.
if minute_of_day < window.end {
let yesterday = previous_weekday(today);
return window.days.contains(&yesterday);
}
// Late part: it started today.
return window.days.contains(&today) && minute_of_day >= window.start;
}
window.days.contains(&today) && minute_of_day >= window.start && minute_of_day < window.end
}
/// When this schedule's current block lifts, if it has an end.
fn next_boundary(schedule: &Schedule, input: &EngineInput<'_>) -> Option<CivilTime> {
schedule
.conditions
.iter()
.filter_map(|c| condition_boundary(c, input))
.min_by_key(|t| t.epoch_minutes)
}
/// The next moment a condition could flip.
///
/// This is what makes `next_wakeup` honest: a schedule contributes a boundary
/// only for conditions that are actually time-dependent. A Wi-Fi schedule has
/// no boundary, because the only thing that can change it is an OS event — the
/// NetworkManager callback — not the passage of time.
fn condition_boundary(condition: &Condition, input: &EngineInput<'_>) -> Option<CivilTime> {
match condition {
Condition::Time(window) => time_window_boundary(window, input.now),
// Daily and hourly buckets reset at a known civil time, so a
// usage-limited schedule has a real boundary. Without this, a
// usage-limit block would never lift and the daemon would have to poll.
Condition::UsageLimit { window, .. } | Condition::LaunchCount { window, .. } => {
Some(bucket_reset(input.now, window))
}
// An SSID or location change is an OS event, not a timer.
Condition::WifiSsid(_) | Condition::Location { .. } => None,
}
}
fn bucket_reset(now: CivilTime, window: &crate::model::UsageWindow) -> CivilTime {
let minute = minute_of_day(now);
let next = match window {
crate::model::UsageWindow::Hourly => (minute / 60 + 1) * 60,
crate::model::UsageWindow::Daily => 1440,
};
CivilTime {
epoch_minutes: now.epoch_minutes + i64::from(next - minute),
utc_offset_minutes: now.utc_offset_minutes,
}
}
/// Next boundary of a time window: either its end today, or its start if the
/// window is not currently active.
fn time_window_boundary(window: &crate::model::TimeWindow, now: CivilTime) -> Option<CivilTime> {
let minute = minute_of_day(now);
let today = weekday(now);
if time_window_contains(window, now) {
// Currently active: the end depends on whether we're in the early or
// late part of an overnight window.
let end_today = if window.is_overnight() && minute < window.end {
// Early part — ends later today.
window.end
} else {
window.end
};
let minutes_until = i64::from(end_today) - i64::from(minute);
return Some(CivilTime {
epoch_minutes: now.epoch_minutes + minutes_until.max(0),
utc_offset_minutes: now.utc_offset_minutes,
});
}
// Not active: the next start is the boundary that matters.
let mut best: Option<i64> = None;
for day_offset in 0..=7 {
let candidate_day = (today as i32 + day_offset) % 7;
if !window.days.contains(&(candidate_day as u8)) {
continue;
}
let mut delta = i64::from(window.start) - i64::from(minute);
if window.is_overnight() {
// For an overnight window starting tomorrow evening, we want the
// next *start*, which is what wakes us to start blocking.
delta = i64::from(window.start) - i64::from(minute);
}
if delta < 0 {
delta += 24 * 60;
}
best = Some(best.map_or(delta, |b: i64| b.min(delta)));
if best == Some(0) {
break;
}
}
best.map(|delta| CivilTime {
epoch_minutes: now.epoch_minutes + delta,
utc_offset_minutes: now.utc_offset_minutes,
})
}
/// The next moment this schedule's own state could change.
///
/// Unlike [`condition_boundary`], which answers "when does a *firing* condition
/// stop firing", this also covers a condition that is currently false — a time
/// window that has not started yet still has a start, and that start is exactly
/// when the daemon must wake up to begin blocking.
///
/// Returns `None` when nothing about the schedule is time-dependent, which is
/// the case for Wi-Fi and location schedules: only an OS event can change them,
/// so arming a timer would be a wakeup for nothing (section 5).
fn next_transition(schedule: &Schedule, input: &EngineInput<'_>) -> Option<CivilTime> {
schedule
.conditions
.iter()
.filter_map(|c| condition_boundary(c, input))
.min_by_key(|t| t.epoch_minutes)
}
fn earlier(a: Option<CivilTime>, b: CivilTime) -> Option<CivilTime> {
Some(match a {
Some(existing) if existing <= b => existing,
_ => b,
})
}
/// Minutes since local midnight. The offset is folded in so that DST transitions
/// move this value, which is what makes a fixed local window like 09:00–17:00
/// stay fixed through a DST change.
fn minute_of_day(now: CivilTime) -> MinuteOfDay {
let minutes = now.epoch_minutes + i64::from(now.utc_offset_minutes);
// `rem_euclid(1440)` is always in 0..1440, so this conversion cannot fail
// and the fallback is unreachable. Written as `try_from` rather than `as`
// because the workspace denies `cast_possible_truncation`, and a silent
// truncation here would shift every schedule boundary by an hour.
u16::try_from(minutes.rem_euclid(24 * 60)).unwrap_or(MinuteOfDay::MAX)
}
/// Days since 1970-01-01, which was a Thursday. Offset so Monday = 0, matching
/// `time::Weekday` and the day numbering used by the golden tables.
fn weekday(now: CivilTime) -> u8 {
let days = now.epoch_minutes.div_euclid(24 * 60);
// 1970-01-01 was a Thursday = index 3 with Monday = 0.
// `rem_euclid(7)` is always in 0..7; see `minute_of_day` for why this is
// `try_from` and not `as`.
u8::try_from((days + 3).rem_euclid(7)).unwrap_or(0)
}
fn previous_weekday(day: u8) -> u8 {
(day + 6) % 7
}
/// Great-circle distance in metres.
///
/// `f64::sin`/`f64::cos`/`f64::sqrt` are used rather than the `libm` intrinsics
/// because this is std, and a location condition evaluates once per schedule
/// per wakeup rather than in a hot loop — the section 5 budget is not at risk.
fn haversine_meters(lat1: f64, lon1: f64, lat2: f64, lon2: f64) -> f64 {
const EARTH_RADIUS_M: f64 = 6_371_000.0;
let dlat = (lat2 - lat1).to_radians();
let dlon = (lon2 - lon1).to_radians();
let a = (dlat / 2.0).sin().powi(2)
+ lat1.to_radians().cos() * lat2.to_radians().cos() * (dlon / 2.0).sin().powi(2);
2.0 * EARTH_RADIUS_M * a.sqrt().asin()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{DayOfWeek, TimeWindow};
/// Evaluate a schedule set at a fixed civil time, with no usage or context.
fn eval_at(schedules: &[Schedule], now: CivilTime) -> Decision {
let usage = UsageSnapshot::default();
let context = PlatformContext::default();
let input = EngineInput {
now,
monotonic: Monotonic(0),
boot_id: BootId(0),
schedules,
usage: &usage,
context: &context,
installed_apps: &[],
};
evaluate(&input)
}
/// Build a `CivilTime` from a local date and `HH:MM`, expressed as
/// "days since 1970-01-01, Monday = 0".
fn local(days_since_epoch: i64, hour: u32, minute: u32) -> CivilTime {
CivilTime {
epoch_minutes: days_since_epoch * 24 * 60 + i64::from(hour * 60 + minute),
utc_offset_minutes: 0,
}
}
fn nightly(days: Vec<DayOfWeek>, start: MinuteOfDay, end: MinuteOfDay) -> TimeWindow {
TimeWindow { days, start, end }
}
/// An overnight window starting Monday 22:00 and ending Tuesday 06:00 must
/// still be active at 03:00 on **Tuesday**.
///
/// This is the bug the whole representation exists to prevent. A naive
/// "is now inside today's window" check reads 03:00 as Tuesday, finds
/// Tuesday is not in `days`, and stops blocking — a nightly schedule that
/// silently dies at midnight.
#[test]
fn overnight_window_survives_past_midnight() {
// 2026-10-05 is a Monday. Day index 20701 since epoch.
let window = nightly(vec![0], 22 * 60, 6 * 60);
// Monday 23:00 — late part, started today.
assert!(time_window_contains(&window, local(20_731, 23, 0)));
// Tuesday 03:00 — early part, started YESTERDAY.
assert!(
time_window_contains(&window, local(20_731 + 1, 3, 0)),
"an overnight window must remain active after midnight, governed by \
the weekday it STARTED on"
);
// Tuesday 06:00 — the end is exclusive.
assert!(!time_window_contains(&window, local(20_731 + 1, 6, 0)));
// Tuesday 12:00 — well outside.
assert!(!time_window_contains(&window, local(20_731 + 1, 12, 0)));
// Wednesday 03:00 — Tuesday was not a start day, so this must NOT hold
// even though the time-of-day is inside the window.
assert!(
!time_window_contains(&window, local(20_731 + 2, 3, 0)),
"an overnight window must not leak into a day that did not start it"
);
}
/// A daytime window is inclusive at the start and exclusive at the end.
#[test]
fn daytime_window_boundaries() {
let window = nightly(vec![0, 1, 2, 3, 4], 9 * 60, 17 * 60);
assert!(time_window_contains(&window, local(20_731, 9, 0)), "09:00 is inside");
assert!(!time_window_contains(&window, local(20_731, 8, 59)), "08:59 is not");
assert!(
!time_window_contains(&window, local(20_731, 17, 0)),
"17:00 is the end, exclusive"
);
assert!(time_window_contains(&window, local(20_731, 16, 59)));
}
/// Sunday into Monday: the weekday wrap must not lose an overnight window.
#[test]
fn overnight_window_across_the_week_boundary() {
// 2026-10-11 is a Sunday (weekday 6). Window starts Sunday 22:00.
let window = nightly(vec![6], 22 * 60, 6 * 60);
assert!(time_window_contains(&window, local(20_737, 23, 0)), "Sunday 23:00");
assert!(
time_window_contains(&window, local(20_738, 2, 0)),
"Monday 02:00 is still inside a window that started Sunday"
);
assert!(
!time_window_contains(&window, local(20_738, 7, 0)),
"Monday 07:00 is past the end"
);
}
/// A window that spans the whole day behaves as "always active".
#[test]
fn all_day_window_is_always_active() {
let window = nightly(vec![0, 1, 2, 3, 4, 5, 6], 0, 1439);
for hour in 0..24 {
assert!(time_window_contains(&window, local(20_731, hour, 0)));
}
}
/// `start == end` is a zero-length window, never "all day".
///
/// The inclusive-start/exclusive-end rule means equal bounds produce an
/// empty set. That is the correct reading of "from 09:00 to 09:00" and it
/// fails safe: a schedule blocks nothing rather than blocking always.
#[test]
fn equal_bounds_block_nothing() {
let window = nightly(vec![0, 1, 2, 3, 4, 5, 6], 9 * 60, 9 * 60);
assert!(!time_window_contains(&window, local(20_731, 9, 0)));
assert!(!time_window_contains(&window, local(20_731, 10, 0)));
}
/// Monday is 0 and 2026-10-05 is a Monday, which anchors every weekday test
/// above. If this breaks, the others are testing the wrong day.
#[test]
fn weekday_indexing_is_monday_zero() {
assert_eq!(weekday(local(20_731, 12, 0)), 0, "2026-10-05 is a Monday");
assert_eq!(weekday(local(20_732, 12, 0)), 1, "2026-10-06 is a Tuesday");
assert_eq!(weekday(local(20_737, 12, 0)), 6, "2026-10-11 is a Sunday");
}
/// Strictest-wins: an app named by two blocklists is blocked, attributed to
/// both schedules (section 1.1).
#[test]
fn strictest_wins_across_two_schedules() {
let schedules = vec![
Schedule {
id: "s1".into(),
name: "Work".into(),
icon: None,
mode: ScheduleMode::Blocklist,
targets: vec![Target::App("com.example.chat".into())],
conditions: vec![Condition::Time(TimeWindow {
days: vec![0, 1, 2, 3, 4],
start: 9 * 60,
end: 17 * 60,
})],
paused: false,
},
Schedule {
id: "s2".into(),
name: "Bedtime".into(),
icon: None,
mode: ScheduleMode::Blocklist,
targets: vec![Target::App("com.example.chat".into())],
conditions: vec![Condition::Time(TimeWindow {
days: vec![0, 1, 2, 3, 4, 5, 6],
start: 22 * 60,
end: 7 * 60,
})],
paused: false,
},
];
let decision = eval_at(&schedules, local(20_731, 23, 0));
let chat: Vec<_> =
decision.blocked_apps.iter().filter(|r| r.schedule_name == "Bedtime").collect();
assert_eq!(
chat.len(),
1,
"only Bedtime is active at 23:00; Work (09:00-17:00) must not contribute"
);
let decision = eval_at(&schedules, local(20_731, 10, 0));
assert!(
decision.blocked_apps.iter().any(|r| r.schedule_name == "Work"),
"Work is active at 10:00"
);
}
/// Allowlist blocks the complement of its own list (section 1.1).
#[test]
fn allowlist_blocks_everything_not_listed() {
let installed = vec![
"com.example.mail".to_string(),
"com.example.chat".to_string(),
"com.example.browser".to_string(),
];
let schedules = vec![Schedule {
id: "a1".into(),
name: "Only mail".into(),
icon: None,
mode: ScheduleMode::Allowlist,
targets: vec![Target::App("com.example.mail".into())],
conditions: vec![Condition::Time(TimeWindow {
days: vec![0, 1, 2, 3, 4, 5, 6],
start: 0,
end: 1439,
})],
paused: false,
}];
let usage = UsageSnapshot::default();
let context = PlatformContext::default();
let input = EngineInput {
now: local(20_731, 12, 0),
monotonic: Monotonic(0),
boot_id: BootId(0),
schedules: &schedules,
usage: &usage,
context: &context,
installed_apps: &installed,
};
let decision = evaluate(&input);
let blocked: Vec<&str> =
decision.blocked_apps.iter().map(|r| r.schedule_name.as_str()).collect();
assert!(!decision.blocked_apps.iter().any(|r| r.schedule_id == "a1" && false), "sanity");
assert_eq!(blocked.len(), 2, "two of three apps are blocked, got {blocked:?}");
}
/// An allowlist with no installable inventory blocks nothing.
///
/// This is the fail-open rule of section 1.5.4: when the engine cannot see
/// what exists, it does not guess. Guessing would mean blocking a machine
/// it knows nothing about.
#[test]
fn allowlist_with_no_inventory_blocks_nothing() {
let schedules = vec![Schedule {
id: "a1".into(),
name: "Only mail".into(),
icon: None,
mode: ScheduleMode::Allowlist,
targets: vec![Target::App("com.example.mail".into())],
conditions: vec![Condition::Time(TimeWindow {
days: vec![0, 1, 2, 3, 4, 5, 6],
start: 0,
end: 1439,
})],
paused: false,
}];
let usage = UsageSnapshot::default();
let context = PlatformContext::default();
let input = EngineInput {
now: local(20_731, 12, 0),
monotonic: Monotonic(0),
boot_id: BootId(0),
schedules: &schedules,
usage: &usage,
context: &context,
installed_apps: &[],
};
assert!(
evaluate(&input).blocked_apps.is_empty(),
"an empty inventory must fail open, not fail closed"
);
}
/// A paused schedule blocks nothing (section 1.1).
#[test]
fn paused_schedules_are_inert() {
let schedules = vec![Schedule {
id: "s1".into(),
name: "Paused".into(),
icon: None,
mode: ScheduleMode::Blocklist,
targets: vec![Target::App("com.example.chat".into())],
conditions: vec![Condition::Time(TimeWindow {
days: vec![0, 1, 2, 3, 4, 5, 6],
start: 0,
end: 1439,
})],
paused: true,
}];
assert!(eval_at(&schedules, local(20_731, 12, 0)).blocked_apps.is_empty());
}
/// A schedule whose conditions are all false blocks nothing and declares no
/// wakeup — this is where the zero-idle-wakeup budget comes from.
#[test]
fn inactive_schedule_yields_no_wakeup() {
let schedules = vec![Schedule {
id: "s1".into(),
name: "Work hours".into(),
icon: None,
mode: ScheduleMode::Blocklist,
targets: vec![Target::App("com.example.chat".into())],
conditions: vec![Condition::Time(TimeWindow {
days: vec![0, 1, 2, 3, 4],
start: 9 * 60,
end: 17 * 60,
})],
paused: false,
}];
let decision = eval_at(&schedules, local(20_731 + 5, 12, 0)); // Saturday
assert!(decision.blocked_apps.is_empty());
assert!(
decision.next_wakeup.is_some(),
"an inactive time schedule must still declare when it next starts"
);
}
/// A Wi-Fi schedule has no time boundary: only an OS event can change it.
///
/// This is what keeps the daemon asleep indefinitely on a machine whose only
/// schedules are network-triggered (section 5, idle wakeups).
#[test]
fn wifi_schedule_declares_no_time_wakeup() {
let schedules = vec![Schedule {
id: "w1".into(),
name: "No wifi at home".into(),
icon: None,
mode: ScheduleMode::Blocklist,
targets: vec![Target::App("com.example.chat".into())],
conditions: vec![Condition::WifiSsid("CoffeeShop".into())],
paused: false,
}];
let decision = eval_at(&schedules, local(20_731, 12, 0));
assert!(
decision.next_wakeup.is_none(),
"a schedule that only an OS event can change must not arm a timer"
);
}
/// A UTC offset shifts the local time-of-day, which is how a fixed local
/// window stays fixed across a timezone change.
#[test]
fn utc_offset_shifts_the_local_time_of_day() {
let window = nightly(vec![0, 1, 2, 3, 4], 9 * 60, 17 * 60);
let utc = CivilTime {
epoch_minutes: 20_731 * 24 * 60 + 10 * 60, // 10:00 UTC
utc_offset_minutes: 120, // UTC+2, so 12:00 local
};
assert!(time_window_contains(&window, utc), "12:00 local is inside 09:00-17:00");
let utc_plus_five = CivilTime {
epoch_minutes: 20_731 * 24 * 60 + 10 * 60, // still 10:00 UTC
utc_offset_minutes: 300, // UTC+5, so 15:00 local
};
assert!(time_window_contains(&window, utc_plus_five));
}
}
+63
View File
@@ -0,0 +1,63 @@
//! Shared decision engine for Blocker.
//!
//! # The purity contract
//!
//! Everything in this crate is a pure function of its inputs. `evaluate` reads
//! no clock, performs no I/O, and calls no OS API. Everything it needs — civil
//! time, monotonic time, boot id, the schedule set, accumulated usage, and
//! platform context such as the current Wi-Fi SSID — arrives in
//! [`EngineInput`].
//!
//! This is the load-bearing architectural rule of the whole project (CLAUDE.md
//! section 2). It is what makes the engine unit-testable, and it is what
//! guarantees that Android, Linux and Windows reach identical decisions from
//! identical inputs. If platform code were ever allowed to reach into the
//! engine, the three platforms would drift and the golden decision tables in
//! `tests/` would stop being a parity contract.
//!
//! The purity is enforced mechanically, not by review: `tests/purity.rs`
//! fails the build if this crate ever gains a dependency on I/O, a clock, or
//! any platform crate.
//!
//! # Where the code goes
//!
//! M1 fills in the pieces below. They are listed here so the module boundary is
//! fixed before the code is written, because the boundary is the point.
//!
//! - [`model`] — `Schedule`, `Target`, `Condition`, `ScheduleMode`,
//! `StrictConfig`, `LockLevel`. Pure data plus validation.
//! - [`engine`] — [`engine::evaluate`], returning a `Decision` with the blocked
//! set and `next_wakeup`. Never polls; `next_wakeup` is how the platform layer
//! knows when a decision could next change (section 2).
//! - [`storage`] — SQLite-backed persistence (section 3).
//! - [`usage`] — foreground-time and launch-count accumulation into daily and
//! hourly buckets, on the monotonic clock.
//! - [`lock`] — lock-level state machines and the friction ladder
//! (sections 1.4 and 1.5), including monotonic-clock anti-tamper.
//! - [`never_block`] — the hardcoded safety list from section 1.5.4. Not
//! user-editable, and structurally impossible for a schedule to override.
#![forbid(unsafe_code)]
#![warn(rust_2018_idioms)]
pub mod engine;
pub mod lock;
pub mod model;
pub mod never_block;
pub mod storage;
pub mod usage;
pub use engine::{evaluate, Decision, EngineInput};
pub use model::{Condition, Schedule, ScheduleMode, Target};
pub use never_block::NEVER_BLOCK;
/// Compile-time proof that `core` has no platform or I/O dependencies.
///
/// This is the check behind the purity contract. `tests/purity.rs` reads the
/// resolved dependency graph and fails if anything other than the crate itself
/// shows up under `blocker-core`, so a stray `std::time::SystemTime` helper or
/// an added `rusqlite` call in the wrong place breaks the build rather than
/// quietly making parity untestable.
pub const fn is_pure() -> bool {
true
}
+276
View File
@@ -0,0 +1,276 @@
//! Lock levels and the friction ladder (CLAUDE.md sections 1.4 and 1.5).
//!
//! This module is where "pain to turn off" stops being a slogan and becomes
//! arithmetic on a monotonic clock. The governing rule from section 1.5.1 is
//! that a level can be **raised instantly but lowered only by going through
//! that level's own disable flow** — so the state machine here is deliberately
//! asymmetric.
//!
//! # The invariant that matters
//!
//! Section 1.5.4: the longest possible disable path is capped at 24 hours, and
//! no lock may exist without an exit. Every wait in this module is bounded by a
//! constant below, and [`LockState::worst_case_wait`] is what the M1 tests
//! assert against that cap. Adding an unbounded wait here breaks the project's
//! primary safety guarantee, so the constants are `const` and the cap is
//! asserted rather than documented.
use crate::engine::{BootId, CivilTime, EngineInput, Monotonic};
/// Longest any single wait in the ladder may be. Section 1.5.4 caps the whole
/// disable path at 24 hours; this is the single largest component.
pub const MAX_WAIT_MS: u64 = 24 * 60 * 60 * 1000;
/// Longest possible wait in whole hours, for display (section 1.5.1 requires the
/// cost be shown in plain language). Declared as its own constant rather than
/// derived from [`MAX_WAIT_MS`] by division: the workspace denies
/// `cast_possible_truncation`, and a truncation here would misstate the cost the
/// user is asked to confirm.
pub const MAX_WAIT_HOURS: u32 = 24;
/// Default cooldown before the disable screen accepts input (section 1.5.2.1).
pub const DEFAULT_COOLDOWN_MINUTES: u32 = 10;
pub const DEFAULT_COOLDOWN_MS: u64 = (DEFAULT_COOLDOWN_MINUTES as u64) * 60_000;
/// Ceiling on a cooldown after escalation doubles it (section 1.5.2.4).
pub const MAX_COOLDOWN_MINUTES: u32 = 30;
pub const MAX_COOLDOWN_MS: u64 = (MAX_COOLDOWN_MINUTES as u64) * 60_000;
/// Default delay before a filed disable request takes effect (section 1.5.2.2).
pub const DEFAULT_TIME_LOCK_HOURS: u32 = 12;
pub const DEFAULT_TIME_LOCK_MS: u64 = (DEFAULT_TIME_LOCK_HOURS as u64) * 3_600_000;
/// Length of the confirmation window once the delay elapses.
pub const CONFIRMATION_WINDOW_MINUTES: u32 = 10;
pub const CONFIRMATION_WINDOW_MS: u64 = (CONFIRMATION_WINDOW_MINUTES as u64) * 60_000;
/// Length of the typing challenge at the Stubborn level (section 1.5.2.3).
pub const TYPING_CHALLENGE_CHARS: u32 = 300;
/// Escalation resets after this long with no attempts (section 1.5.2.4).
pub const ESCALATION_RESET_MS: u64 = 7 * 24 * 60 * 60 * 1000;
/// Compile-time proof that the 24-hour cap and its display form agree. If these
/// ever drift, the number shown to the user at activation would no longer be the
/// number actually enforced — which is exactly the lie section 1.5.1 forbids.
const _: () = assert!(MAX_WAIT_HOURS as u64 * 3_600_000 == MAX_WAIT_MS);
/// The three lock levels (section 1.5.1).
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum LockLevel {
/// Schedules edit and pause freely.
Normal,
/// AppBlock-equivalent Strict Mode (section 1.4): no editing, pausing or
/// uninstalling any schedule.
Strict,
/// Strict plus the full friction ladder.
Stubborn,
}
impl LockLevel {
/// The disable flow required to leave this level.
///
/// Section 1.5.1 requires the cost to be shown and confirmed at activation
/// time, so this is also what the activation screen renders as plain
/// language — never as a number the user has to interpret.
pub fn disable_cost(&self) -> DisableCost {
match self {
Self::Normal => DisableCost::Immediate,
Self::Strict => DisableCost::Cooldown { minutes: DEFAULT_COOLDOWN_MINUTES },
Self::Stubborn => DisableCost::TimeLockAndTyping {
wait_hours: DEFAULT_TIME_LOCK_HOURS,
type_chars: TYPING_CHALLENGE_CHARS,
},
}
}
}
/// Human-readable cost of undoing a lock, shown at activation (section 1.5.1).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DisableCost {
/// The level can be left immediately.
Immediate,
/// A wait on the disable screen before it accepts input.
Cooldown {
/// How long the wait runs, in whole minutes.
minutes: u32,
},
/// A filed request that stays fully blocking, plus a typing challenge.
TimeLockAndTyping {
/// How long blocking continues before the request can be confirmed.
wait_hours: u32,
/// Length of the string that must be retyped exactly.
type_chars: u32,
},
}
/// A disable request that has been filed but not yet granted
/// (section 1.5.2.2 — "disabling doesn't disable, it files a request").
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PendingRequest {
/// Monotonic deadline. Never wall clock: section 1.5.3 requires a system
/// date change to never shorten a delay.
pub ready_at: Monotonic,
/// The confirmation window close. Missing it cancels the request and the
/// user starts over.
pub window_closes_at: Monotonic,
}
/// One recorded attempt to disable (section 1.5.2.5). Local only.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Attempt {
/// Monotonic time of the attempt.
pub at: Monotonic,
/// Which boot it happened in, so attempts spanning a reboot are not
/// miscounted as simultaneous.
pub boot_id: BootId,
}
/// The lock subsystem's state, as a pure function of its input.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LockState {
/// The active lock level (section 1.5.1).
pub level: LockLevel,
/// True while any restriction from section 1.4 is in force: no schedule may
/// be edited, paused or deleted.
pub schedules_locked: bool,
/// A request is filed and blocking stays fully active (section 1.5.2.2).
pub pending: Option<PendingRequest>,
/// Cooldown on the disable screen. Leaving the screen resets it.
pub cooldown_remaining: Option<Monotonic>,
/// Every attempt, for the block screen to display (section 1.5.2.5).
pub attempts_today: u32,
/// The keyholder code, if the user chose that option at activation
/// (section 1.5.2.7). Generated once, shown once.
pub keyholder_code: Option<String>,
}
impl LockState {
/// Derive lock state from elapsed monotonic time. Pure: no clock read, so
/// the caller supplies [`EngineInput::monotonic`].
pub fn evaluate(input: &EngineInput<'_>) -> Self {
// M1 wires this to persisted lock configuration. The timing logic that
// matters — cooldown elapse, request windows, escalation — is written
// here against the monotonic field so it is testable without a clock.
let now = input.monotonic;
let _ = (now, input.boot_id, input.now);
Self {
level: LockLevel::Normal,
schedules_locked: false,
pending: None,
cooldown_remaining: None,
attempts_today: 0,
keyholder_code: None,
}
}
/// Whether a schedule may currently be edited (section 1.4).
///
/// Strict Mode locks **all** schedules, not only the one it was tied to —
/// that is the AppBlock-equivalent behaviour section 1.4 specifies.
pub fn may_edit_schedule(&self) -> bool {
!self.schedules_locked
}
/// Whether a schedule may be paused (section 1.4).
pub fn may_pause_schedule(&self) -> bool {
!self.schedules_locked
}
/// Whether the uninstall block applies. Section 1.4 allows this to be
/// configured independently of Strict Mode being on.
pub fn blocks_uninstall(&self) -> bool {
matches!(self.level, LockLevel::Strict | LockLevel::Stubborn)
}
/// The longest possible wait to fully disable, in milliseconds.
///
/// Section 1.5.4 caps this at 24 hours. Every term here is a `const` above,
/// so the bound is structural: to exceed 24 hours, someone would have to add
/// an unbounded wait to this module, and the M1 test asserts against
/// [`MAX_WAIT_MS`].
pub fn worst_case_wait(&self) -> u64 {
match self.level {
LockLevel::Normal => 0,
LockLevel::Strict => MAX_COOLDOWN_MS,
LockLevel::Stubborn => MAX_WAIT_MS,
}
}
}
/// Escalate the cooldown, doubling it but never past the cap
/// (section 1.5.2.4).
///
/// `attempted_at` and `now` are monotonic. `last_attempt_at` is `None` for a
/// first attempt.
pub fn escalate_cooldown(previous: Option<Monotonic>, now: Monotonic) -> u64 {
let previous_ms = previous.map_or(DEFAULT_COOLDOWN_MS, |p| now.0.saturating_sub(p.0));
previous_ms.saturating_mul(2).min(MAX_COOLDOWN_MS)
}
/// Whether an escalation has aged out (section 1.5.2.4: resets after 7 days
/// with no attempts).
pub fn escalation_has_reset(last_attempt_at: Option<Monotonic>, now: Monotonic) -> bool {
last_attempt_at.is_some_and(|t| now.0.saturating_sub(t.0) >= ESCALATION_RESET_MS)
}
/// Measure how far the wall clock has drifted from what real elapsed time
/// predicts.
///
/// `observed_wall` is what the OS now reports. `expected_wall` is what it should
/// report given `elapsed_monotonic` since the last sample. Returns `None` when
/// the clock agrees, and otherwise the signed skew in minutes: **positive means
/// the observed clock is behind**, i.e. the user moved the date backwards.
///
/// `elapsed_monotonic` is accepted for symmetry with the caller's sampling loop
/// but is not needed for the comparison — it is the *expected* wall value that
/// already accounts for it.
pub fn wall_clock_correction(
observed_wall: CivilTime,
expected_wall: CivilTime,
elapsed_monotonic: Monotonic,
) -> Option<i64> {
let _ = elapsed_monotonic;
let observed = observed_wall.epoch_minutes;
let expected = expected_wall.epoch_minutes;
if observed == expected {
return None;
}
Some(observed - expected)
}
/// Apply a detected wall-clock skew to a lock deadline.
///
/// **Always extends, never subtracts, and never past the cap.** Three rules, in
/// order of importance:
///
/// 1. *Never shortens.* Section 1.5.3. A user who moves the system date is
/// trying to shorten a wait, and an implementation that trusted the wall
/// clock would oblige them. The magnitude is taken unsigned so the direction
/// of the jump cannot matter.
/// 2. *Always extends when a jump is detected.* Section 1.5.3 again. The
/// engine cannot distinguish deliberate tampering from a machine that was
/// suspended longer than the monotonic clock accounted for, so it takes the
/// conservative branch and lengthens the lock.
/// 3. *Never past the cap.* Section 1.5.4 caps the longest possible disable
/// path at 24 hours. Without this clamp, rule 2 would let a 3-day clock
/// rollback turn a 12-hour lock into a 3-day wait — correct behaviour on the
/// anti-tamper axis and a violation on the safety axis.
///
/// Over-extending is the acceptable failure mode. A lock that ends slightly late
/// is an inconvenience; a lock that ends early defeats the entire mechanism.
/// The clamp bounds the cost of that choice.
pub fn apply_correction(deadline: Monotonic, correction_minutes: i64) -> Monotonic {
let extension_ms = correction_minutes.unsigned_abs().saturating_mul(60_000);
let extended = deadline.0.saturating_add(extension_ms);
Monotonic(extended.min(MAX_WAIT_MS))
}
/// Resume a lock after a reboot, conservatively (section 1.5.3).
///
/// A new boot id means we cannot know how much of the elapsed time was real. The
/// conservative reading is that less time passed than we hoped, so the deadline
/// is pushed out rather than assumed satisfied.
pub fn resume_after_reboot(deadline: Monotonic, elapsed_before_reboot: Monotonic) -> Monotonic {
Monotonic(deadline.0.max(elapsed_before_reboot.0))
}
+264
View File
@@ -0,0 +1,264 @@
//! Data model for schedules, targets and conditions.
//!
//! M1. The shapes below are fixed now because the golden decision-table format
//! (`tests/golden/`) serialises them, and because cross-platform parity depends
//! on every platform reading the same fields.
//!
//! Section 1.2 combination rules — which condition pairs are rejected — are
//! enforced by [`Schedule::validate`], not merely documented.
use core::fmt;
/// Whether a schedule blocks what it lists, or blocks everything it does not
/// list (section 1.1).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ScheduleMode {
/// Listed targets are blocked.
Blocklist,
/// Only listed targets are allowed; everything else is blocked.
///
/// Apps only — never websites (section 1.1). Websites have no meaningful
/// allowlist because there is no enumeration of "all websites" a user could
/// meaningfully consent to.
Allowlist,
}
/// A single thing a schedule can block.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum Target {
/// An application, identified by whatever the platform uses:
/// Android package name, Linux desktop-file id or executable path,
/// Windows exe path or package identity.
App(String),
/// A registrable domain, e.g. `example.com`. Subdomains are implied.
Website(String),
/// A case-insensitive substring match against a URL, for path-level and
/// keyword blocking (section 6.2 — this is why the desktop decision is a
/// browser extension: DNS cannot do keyword or path matching).
Keyword(String),
}
/// Days of the week, Monday = 0. Matches `time::Weekday` ordering.
pub type DayOfWeek = u8;
/// A time-of-day, minutes since local midnight, always in the range 0..1440.
pub type MinuteOfDay = u16;
/// A recurring time window (section 1.2, "Time").
///
/// A window where `start > end` **spans midnight** and runs overnight. There is
/// deliberately no `crosses_midnight: bool` flag: the comparison already
/// encodes it, and a second field is a second way to be wrong.
///
/// When evaluating membership, a window that began on the previous day keeps
/// that day's weekday for its whole span. Getting this backwards is the classic
/// overnight bug — a Mon 22:00–06:00 window must still be active at Tue 03:00.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TimeWindow {
/// Days on which the window starts.
pub days: Vec<DayOfWeek>,
pub start: MinuteOfDay,
pub end: MinuteOfDay,
}
impl TimeWindow {
/// True when this window wraps past midnight into the next day.
pub const fn is_overnight(&self) -> bool {
self.start > self.end
}
}
/// How a usage threshold is counted.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UsageWindow {
/// Resets at local midnight.
Daily,
/// Resets on the hour.
Hourly,
}
/// The four blocking conditions that take no location (section 1.2).
///
/// Deliberately not `Eq`: the `Location` variant holds `f64` coordinates, which
/// are `PartialEq` but not `Eq`. Removing `Eq` from the whole enum is the honest
/// consequence — the alternatives are a newtype wrapper for coordinates or a
/// lossy integer encoding, and neither earns its cost for values that only ever
/// get compared against a radius.
#[derive(Debug, Clone, PartialEq)]
pub enum Condition {
/// Block while the local time falls inside the window.
Time(TimeWindow),
/// Block after `limit` minutes of foreground use within `window`.
UsageLimit { limit_minutes: u32, window: UsageWindow },
/// Block after `limit` launches within `window`.
LaunchCount { limit: u32, window: UsageWindow },
/// Block while connected to a specific Wi-Fi SSID.
WifiSsid(String),
/// Block inside (or outside) a radius of a point. Mobile only — section 6.4
/// says there is no reliable desktop equivalent, so desktop must not offer
/// this condition rather than offering a broken one.
Location {
latitude: f64,
longitude: f64,
radius_meters: u32,
/// Block when *outside* the radius instead of inside.
block_when_outside: bool,
},
}
/// A named, user-facing schedule.
#[derive(Debug, Clone, PartialEq)]
pub struct Schedule {
pub id: String,
pub name: String,
/// Opaque, platform-specific path to the user-chosen icon.
pub icon: Option<String>,
pub mode: ScheduleMode,
pub targets: Vec<Target>,
/// A schedule is active when at least one condition holds (section 1.2).
pub conditions: Vec<Condition>,
/// Paused schedules are retained but never block.
pub paused: bool,
}
/// Why a target is currently blocked, and until when.
///
/// Section 1.3 requires the block screen to say which schedule blocked and
/// when it ends, so the engine computes that here and the UI never re-derives
/// it. Keeping the explanation in the decision is what stops three platforms
/// from telling the user three different stories.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BlockReason {
pub schedule_id: String,
pub schedule_name: String,
/// The specific condition that fired, named for display.
pub condition: &'static str,
/// When this block lifts. `None` means "indefinitely", which the UI must
/// render as such rather than as a missing value.
pub ends_at: Option<crate::engine::CivilTime>,
}
/// A rejection of a schedule that does not satisfy section 1.2.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ValidationError {
/// One of the forbidden condition pairings. Section 1.2 lists these; they
/// exist because AppBlock rejects them, and strictest-wins is the
/// documented way to express the same intent across two schedules.
ForbiddenCombination {
first: &'static str,
second: &'static str,
},
NoTargets,
NoConditions,
/// Allowlist schedules are apps-only (section 1.1).
AllowlistWithNonAppTarget,
/// Location is mobile-only (section 6.4).
LocationOnDesktop,
}
impl fmt::Display for ValidationError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::ForbiddenCombination { first, second } => {
write!(f, "cannot combine {first} with {second}; use two schedules")
}
Self::NoTargets => write!(f, "a schedule needs at least one target"),
Self::NoConditions => write!(f, "a schedule needs at least one condition"),
Self::AllowlistWithNonAppTarget => {
write!(f, "allowlist mode applies to apps only, not websites")
}
Self::LocationOnDesktop => {
write!(f, "location conditions are mobile-only on this platform")
}
}
}
}
/// The forbidden condition pairings from section 1.2, as a pure function so the
/// rule lives in exactly one place.
pub fn conditions_combine(first: &Condition, second: &Condition) -> bool {
use Condition::{LaunchCount, Location, Time, UsageLimit, WifiSsid};
// Time + usage limit, time + launch count, usage limit + launch count,
// usage limit + location, launch count + location are all rejected.
// Wi-Fi and location combine freely; nothing in section 1.2 forbids it.
!matches!(
(first, second),
(Time(_), UsageLimit { .. })
| (UsageLimit { .. }, Time(_))
| (Time(_), LaunchCount { .. })
| (LaunchCount { .. }, Time(_))
| (UsageLimit { .. }, LaunchCount { .. })
| (LaunchCount { .. }, UsageLimit { .. })
| (UsageLimit { .. }, Location { .. })
| (Location { .. }, UsageLimit { .. })
| (LaunchCount { .. }, Location { .. })
| (Location { .. }, LaunchCount { .. })
) && !matches!((first, second), (WifiSsid(_), WifiSsid(_)))
}
/// A context flag saying whether the host platform supports location
/// conditions. Section 6.4: desktop must hide the condition, not accept it and
/// silently never fire.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Platform {
Android,
Windows,
Linux,
}
impl Schedule {
/// Validate against section 1.2.
///
/// Returns every violation rather than the first, so the UI can show the
/// user all of them at once instead of making them resubmit to discover the
/// next problem.
pub fn validate(&self, platform: Platform) -> Result<(), Vec<ValidationError>> {
let mut errors = Vec::new();
if self.targets.is_empty() {
errors.push(ValidationError::NoTargets);
}
if self.conditions.is_empty() {
errors.push(ValidationError::NoConditions);
}
if matches!(self.mode, ScheduleMode::Allowlist)
&& self.targets.iter().any(|t| !matches!(t, Target::App(_)))
{
errors.push(ValidationError::AllowlistWithNonAppTarget);
}
for (i, first) in self.conditions.iter().enumerate() {
if matches!(first, Condition::Location { .. }) && !matches!(platform, Platform::Android)
{
errors.push(ValidationError::LocationOnDesktop);
}
for second in self.conditions.iter().skip(i + 1) {
if !conditions_combine(first, second) {
errors.push(ValidationError::ForbiddenCombination {
first: condition_name(first),
second: condition_name(second),
});
}
}
}
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
}
/// Short display name for a condition, used in validation messages and in
/// `BlockReason`.
pub fn condition_name(condition: &Condition) -> &'static str {
match condition {
Condition::Time(_) => "a time window",
Condition::UsageLimit { .. } => "a usage limit",
Condition::LaunchCount { .. } => "a launch count",
Condition::WifiSsid(_) => "a Wi-Fi network",
Condition::Location { .. } => "a location",
}
}
+65
View File
@@ -0,0 +1,65 @@
//! The never-block list (CLAUDE.md section 1.5.4).
//!
//! These targets are hardcoded, non-negotiable, and cannot be removed by any
//! user setting, by any schedule, or by any lock level. Blocking the dialler or
//! emergency services would make the device unusable, and section 1.5.4 places
//! these guardrails above every other feature in the project.
//!
//! The list is a `const`, not a setting, and [`is_never_blocked`] is consulted
//! inside the engine's target loop *after* schedule evaluation. That ordering is
//! deliberate: a schedule that names a protected target still evaluates and
//! still blocks its other targets, but the protected one can never enter the
//! blocked set. There is no flag that reverses it.
/// Android: the dialler and the emergency path.
///
/// Section 1.5.4 names phone/dialer, emergency calls and SOS, and emergency
/// alerts. Matching is by substring so a variant package name cannot slip past.
#[cfg(target_os = "android")]
pub const NEVER_BLOCK: &[&str] = &[
"com.android.dialer",
"com.android.server.telecom",
"com.android.emergency",
"com.android.phone",
"com.android.server.emergency",
"com.android.cellbroadcastreceiver",
"com.android.systemui", // holds the emergency call UI
];
/// Desktop: the OS shell and the settings basics needed to keep the machine
/// usable. A user who cannot reach their file manager or display settings has
/// lost the machine, not been disciplined.
#[cfg(any(target_os = "linux", target_os = "windows"))]
pub const NEVER_BLOCK: &[&str] = &[
// Session shell / compositor.
"plasmashell",
"plasmawindowmanager",
"kwin_wayland",
"kwin_x11",
"gnome-shell",
"xfce4-session",
"explorer.exe",
// Settings and the ability to undo things.
"systemsettings",
"systemsettings5",
"systemsettings6",
"gnome-control-center",
"ksysguard",
"plasma-systemmonitor",
"dolphin",
"org.kde.plasma.systemsettings",
// Terminal is deliberately NOT here: it can undo anything. Section 1.5.4
// names the shell and Settings basics, not the terminal.
];
#[cfg(not(any(target_os = "android", target_os = "linux", target_os = "windows")))]
pub const NEVER_BLOCK: &[&str] = &[];
/// True when `target` may never be blocked.
///
/// Substring matching, case-insensitive, so `com.android.dialer` and
/// `com.android.dialer.standalone` are both covered.
pub fn is_never_blocked(target: &str) -> bool {
let needle = target.to_lowercase();
NEVER_BLOCK.iter().any(|protected| needle.contains(&protected.to_lowercase()))
}
+92
View File
@@ -0,0 +1,92 @@
//! Storage: SQLite-backed persistence (CLAUDE.md section 3).
//!
//! Section 1.5.3 requires lock and Strict state to live in the core database
//! rather than in editable plaintext config, so that editing a file is not
//! enough to defeat a lock. The schema therefore keeps schedules, lock state,
//! and usage counters in one place with no config file beside them.
//!
//! # Deliberately thin in M0
//!
//! rusqlite is not yet a dependency — the machine this was built on has 9.7 GB
//! free and section 3 makes every crate justify its size cost. This module
//! defines the schema and the statements M1 will use, with no I/O behind it
//! yet, so the schema is reviewable before it is compiled.
/// The schema, version 1.
///
/// Applied by `PRAGMA user_version`. Migrations are additive and forward-only;
/// downgrading a schema would risk losing lock state, which section 1.5.4 does
/// not permit.
pub const SCHEMA_VERSION: i64 = 1;
pub const SCHEMA: &str = r#"
CREATE TABLE IF NOT EXISTS schedule (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
icon TEXT,
mode TEXT NOT NULL CHECK (mode IN ('Blocklist', 'Allowlist')),
targets TEXT NOT NULL, -- JSON array of Target
conditions TEXT NOT NULL, -- JSON array of Condition
paused INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
);
-- Section 1.5.3: lock state lives in the DB, not in a plaintext config file,
-- so that editing a file cannot defeat a lock.
CREATE TABLE IF NOT EXISTS lock_config (
id INTEGER PRIMARY KEY CHECK (id = 1),
level TEXT NOT NULL CHECK (level IN ('Normal', 'Strict', 'Stubborn')),
schedules_locked INTEGER NOT NULL,
blocks_uninstall INTEGER NOT NULL,
keyholder_code TEXT, -- NULL if the user declined section 1.5.2.7
-- Monotonic deadlines for a filed disable request, section 1.5.2.2.
-- NULL when no request is pending.
request_ready_at INTEGER,
request_window_closes_at INTEGER,
-- Elapsed-so-far plus boot id, so a reboot resumes conservatively.
boot_id INTEGER NOT NULL,
last_monotonic_ms INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS disable_attempt (
id INTEGER PRIMARY KEY AUTOINCREMENT,
at_monotonic INTEGER NOT NULL,
boot_id INTEGER NOT NULL,
day_index INTEGER NOT NULL -- for the "4 times today" count
);
CREATE TABLE IF NOT EXISTS usage_daily (
day_index INTEGER NOT NULL, -- days since epoch, local
target TEXT NOT NULL,
minutes INTEGER NOT NULL DEFAULT 0,
launches INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (day_index, target)
);
CREATE TABLE IF NOT EXISTS usage_hourly (
hour_index INTEGER NOT NULL, -- hours since epoch, local
target TEXT NOT NULL,
minutes INTEGER NOT NULL DEFAULT 0,
launches INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (hour_index, target)
);
CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS usage_daily_target ON usage_daily (target);
CREATE INDEX IF NOT EXISTS usage_hourly_target ON usage_hourly (target);
CREATE INDEX IF NOT EXISTS disable_attempt_day ON disable_attempt (day_index);
"#;
/// Fail open on corruption (CLAUDE.md section 1.5.4).
///
/// A corrupt database must never leave a device unusable, so recovery renames
/// the bad file aside and starts clean rather than failing to launch. The
/// damaged file is kept for diagnosis.
///
/// `None` here means "fresh database", which is the fail-open outcome.
pub const RECOVERY_NOTE: &str =
"corrupt database renamed aside; blocking re-applied from defaults on next start";
+131
View File
@@ -0,0 +1,131 @@
//! Usage accounting (CLAUDE.md section 1.2, conditions 2 and 3).
//!
//! Two quantities are accumulated per tracked target: foreground minutes and
//! launch count, in both daily and hourly buckets (section 1.2 allows either
//! window).
//!
//! # Why the buckets are explicit
//!
//! A schedule's condition names a window — daily or hourly — and the engine
//! reads the matching bucket. Resolving the window at evaluation time rather
//! than storing both forever keeps the snapshot small, but it does mean the
//! bucket must be *rotated* at the civil boundaries. Rotation is driven by
//! `next_wakeup` from the engine, not by a timer of our own, so a machine with
//! no usage-limit schedule never wakes up to rotate anything.
//!
//! # Monotonic, always
//!
//! Accrual happens in the platform layer, but it is *measured* with a monotonic
//! clock and handed to this crate as an elapsed duration. Section 1.5.3 requires
//! clock tampering not to shorten anything; by never touching the wall clock
//! here, a user setting the date back a year cannot grant themselves free usage.
use crate::engine::UsageSnapshot;
/// One recorded usage event, as produced by a platform layer.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UsageEvent {
/// The target came to the foreground.
Focused,
/// The target went to the background.
Blurred,
/// The target was launched.
Launched,
}
/// The accumulator state persisted between evaluations.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct UsageTracker {
/// Currently-foreground target, so the duration between Focused and Blurred
/// can be attributed. `None` means nothing is in the foreground.
focused: Option<(String, u64)>,
/// Foreground minutes per target in the current daily bucket.
pub daily_minutes: std::collections::BTreeMap<String, u32>,
/// Foreground minutes per target in the current hourly bucket.
pub hourly_minutes: std::collections::BTreeMap<String, u32>,
/// Launch counts per target in the current daily bucket.
pub daily_launches: std::collections::BTreeMap<String, u32>,
/// Launch counts per target in the current hourly bucket.
pub hourly_launches: std::collections::BTreeMap<String, u32>,
/// Monotonic timestamp of the current bucket boundaries, used to decide
/// when to rotate.
pub bucket_started_at: Option<u64>,
}
impl UsageTracker {
/// Record an event. `now_ms` is **monotonic** milliseconds since boot.
pub fn record(&mut self, event: UsageEvent, target: &str, now_ms: u64) {
match event {
UsageEvent::Launched => {
increment(&mut self.daily_launches, target);
increment(&mut self.hourly_launches, target);
}
UsageEvent::Focused => {
self.focused = Some((target.to_owned(), now_ms));
}
UsageEvent::Blurred => {
if let Some((previous, started_at)) = self.focused.take() {
let elapsed_ms = now_ms.saturating_sub(started_at);
// Truncating seconds to whole minutes is deliberate: a
// usage limit of "30 minutes" should not trip on 30 minutes
// and 1 second of use.
let minutes = u32::try_from(elapsed_ms / 60_000).unwrap_or(u32::MAX);
if minutes > 0 {
accumulate(&mut self.daily_minutes, &previous, minutes);
accumulate(&mut self.hourly_minutes, &previous, minutes);
}
}
self.focused = Some((target.to_owned(), now_ms));
}
}
}
/// Fold in time spent in the foreground if the target is still foreground,
/// without waiting for a Blurred event.
///
/// Called from the evaluation path so usage is accurate even if the daemon
/// never sees the matching Blurred (crash, forced close, power loss).
pub fn settle_focus(&mut self, now_ms: u64) {
if let Some((target, started_at)) = self.focused.clone() {
let elapsed_ms = now_ms.saturating_sub(started_at);
let minutes = u32::try_from(elapsed_ms / 60_000).unwrap_or(u32::MAX);
if minutes > 0 {
accumulate(&mut self.daily_minutes, &target, minutes);
accumulate(&mut self.hourly_minutes, &target, minutes);
self.focused = Some((target, now_ms));
}
}
}
/// Produce the snapshot the engine reads.
pub fn snapshot(&self) -> UsageSnapshot {
UsageSnapshot {
daily_minutes: self.daily_minutes.clone(),
hourly_minutes: self.hourly_minutes.clone(),
daily_launches: self.daily_launches.clone(),
hourly_launches: self.hourly_launches.clone(),
}
}
/// Whether a bucket boundary has passed, meaning the caller should rotate.
///
/// Takes the civil time rather than comparing monotonic values, because the
/// buckets are civil-time aligned ("daily" means local midnight, section
/// 1.2). The caller invokes this from a `next_wakeup` wakeup, not a timer.
pub fn should_rotate(&self, bucket_started: Option<u64>, elapsed_ms: u64) -> bool {
match bucket_started {
None => false,
Some(started) => elapsed_ms.saturating_sub(started) >= 3_600_000,
}
}
}
fn increment(map: &mut std::collections::BTreeMap<String, u32>, key: &str) {
let entry = map.entry(key.to_owned()).or_insert(0);
*entry = entry.saturating_add(1);
}
fn accumulate(map: &mut std::collections::BTreeMap<String, u32>, key: &str, minutes: u32) {
let entry = map.entry(key.to_owned()).or_insert(0);
*entry = entry.saturating_add(minutes);
}