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:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user