4 Commits

Author SHA1 Message Date
SellPL 1318515103 Merge pull request 'M1: golden decision tables (42 cases), plus two engine fixes' (#3) from m1/golden-tables into m1/storage 2026-10-02 13:03:48 +00:00
SellPL f05836f528 feat(tests): add the golden decision tables, and fix two engine bugs they found
CI / ci (pull_request) Has been cancelled
CI / android (pull_request) Has been cancelled
The parity contract from CLAUDE.md section 7 now exists: 42 cases across 11
scenario groups, with a runner that asserts every case and a coverage check that
asserts every mandatory scenario is claimed.

**A design change, and why.** ADR-16 originally specified JSON tables, justified
by "serde_json will already be in the tree". That was wrong — `rusqlite` does not
pull `serde` by default and `uniffi` has not landed, so nothing provided it, and
`serde` is not a section 3 dependency (rule 0.3). The tables are therefore a Rust
builder DSL: the same declarations, but a typo in a field name is a compile error
rather than a test that silently asserts the wrong thing. For a document whose
entire job is to be a contract, compile-time checking beats diffability.
Converting to JSON later is mechanical if serde is approved.

**Two engine bugs the tables caught, both real:**

1. **Overnight windows woke the daemon immediately.** `time_window_boundary`
   computed the end as `end - minute` and clamped at zero. For a 22:00–06:00
   window active at 23:00, "06:00 today" is in the past, so the boundary became
   *now* — and the daemon would wake, re-evaluate, get the same answer, and wake
   again, forever. Not a missed block, but a busy loop burning the section 5 idle
   budget. Fixed by detecting the late half of an overnight window and crossing
   midnight properly.

2. **An inactive schedule woke a day early.** The next-start search iterated day
   offsets to test membership but computed the delta as `start - minute` only,
   wrapping by 1440 without advancing the day. A Monday–Friday schedule
   evaluated on Saturday reported Monday's start as 21 hours away — Sunday 09:00,
   a day not in the set. The block still fired, because the next evaluation
   recomputed it, but the daemon woke a day early for nothing. Fixed by folding
   the day offset into the delta.

**Four of my own table cases were wrong too**, which is the other half of why
they are worth having: a UTC+5 offset is 15:00 local and therefore *inside*
09:00–17:00 (I had asserted outside); the same for the DST cases. The engine was
right and the test was wrong in all four.

**Coverage bookkeeping is checked, not promised.** `coverage::Location` records
whether a scenario is covered by a table or by a code fixture — clock tampering
and the 24-hour cap are about the passage of time and a bound across lock levels,
not a decision at an instant, so they cannot be expressed as data. Both
directions fail: an uncovered scenario is an error, and so is a `covers` tag that
is not on the mandatory list, which is how a case quietly stops testing what its
name claims.

**`BlockReason` now carries its target.** A schedule may name twenty targets and
they do not all become blocked — some are paused, some belong to a condition that
is not currently active, some are on the never-block list. Without carrying which
target a reason is about, two blocked apps under one schedule produce two
identical reasons and the UI has to re-derive the pairing, which is exactly the
drift `BlockReason` exists to prevent. The tables could not assert per-app
attribution without it.

**The DST gap is recorded, not papered over.** `CivilTime` is an epoch plus an
offset with no timezone database behind it, so the tables pin that a fixed local
window stays fixed as the offset moves — including across a DST transition — but
cannot express a spring-forward time that does not exist or a fall-back time that
is ambiguous. Those are section 7 cases that are genuinely not covered, and
LIMITATIONS.md now says so.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-01 18:29:46 +03:00
SellPL 92afdc3bf6 docs: record the measured storage cost and the new crate boundary
CI / ci (pull_request) Has been cancelled
CI / android (pull_request) Has been cancelled
Updates the ADR log, architecture, limitations, perf table and README to match
what now exists, rather than what was expected.

**The perf number was wrong and is now honest.** The M0 figure of 0.279 MB
excluded SQLite entirely — the placeholder binary never referenced the crate, so
dead-code elimination dropped it. With the daemon actually opening a database it
is **1.364 MB**, of which bundled SQLite is ~1.1 MB. That is 1.1 MB of the 5 MB
budget spoken for, leaving 3.8 MB for D-Bus, X11 and a UI toolkit.

This is the first number that makes the section 5 budget feel like a budget
rather than a formality, and it turns ADR-4's bundled-vs-system-linked choice
into a real trade-off with a measured price on both sides.

- ADR-4 marked accepted and implemented, with that trade-off left open.
- ARCHITECTURE.md documents why `blocker-storage` is a sibling crate and not a
  submodule: the dependency direction is what keeps the zero-dependency purity
  proof automatic rather than a grep someone can route around.
- LIMITATIONS.md drops the stale "storage has no I/O" shortfall and records the
  bundled-vs-system question instead, plus the fact that `LockState` still always
  evaluates to Normal.
- README status table and layout updated.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-01 17:17:11 +03:00
SellPL 9a5c529a8e feat(storage): add SQLite persistence in a separate crate
Implements ADR-4 (CLAUDE.md section 3, `rusqlite` bundled). Schedules, lock
state, disable attempts and usage buckets now persist across restarts.

**Why a new crate rather than a module inside `blocker-core`.** The purity
contract says the engine does no I/O, and `tests/purity.rs` enforces that
mechanically by failing if `blocker-core` has any dependency at all. Putting
rusqlite in `blocker-core` would have forced that check to degrade into a
source-text grep — something a careful developer can route around and nobody
reads in six months. `blocker-storage` depends on `blocker-core`; the reverse
edge does not exist, so the zero-dependency proof stays automatic.

Both crates sit in the same layer of the section 2 diagram: storage is shared by
every platform and contains no platform code.

**Schema is relational, not JSON-in-a-text-column.** `schedule_target` and
`schedule_condition` are separate tables because target rows are queried by
target, and an index on a JSON string is not a plan. Serialisation is
hand-written in `storage/src/codec.rs` — `serde` is not a section 3 dependency,
and section 0 rule 3 requires asking before adding one.

**Fail open, verified end to end** (section 1.5.4). Corruption moves the damaged
file aside and starts clean, keeping the original for diagnosis. Confirmed
against the real binary, not only in tests:

```
$ printf 'SQLite format 3\x00 not a database' > .../blocker.db
$ blocker-daemon
blocker-daemon: database was corrupt and has been moved to
  .../blocker.db.corrupt; starting clean, so no schedules are active
  until you re-add them
EXIT=0
```

A truncated file is covered separately — that is what a power loss during a
write actually produces, and it fails differently from garbage bytes.

**Two bugs this PR's own tests caught:**

1. `prune_usage` anchored its cutoff on `MIN(day_index)`, the oldest row. Since
   that row is by definition the one being tested, the cutoff always sat before
   it and the delete matched nothing — the table grew forever, which is the slow
   disk leak the function exists to prevent. Now anchored on `MAX`.
2. An off-by-one in the same function: with a strict `<` against
   `newest - keep_days`, the boundary day was kept too, yielding `keep_days + 1`
   rows. Both are covered by `old_usage_is_pruned`.

**Cost, measured rather than assumed:** the stripped daemon goes from 0.279 MB
to **1.364 MB** with SQLite actually linked in — bundled SQLite is ~1.1 MB of
the 5 MB budget, leaving 3.8 MB for D-Bus, X11 and a UI toolkit. That is a real
margin rather than a comfortable one, and it makes ADR-4's bundled-vs-system
trade-off a genuine open question rather than a settled one.

The M0 figure of 0.279 MB excluded SQLite entirely, because dead-code elimination
dropped a crate the placeholder binary never referenced.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-01 17:14:16 +03:00
21 changed files with 3164 additions and 314 deletions
Generated
+116
View File
@@ -2,6 +2,12 @@
# It is not intended for manual editing. # It is not intended for manual editing.
version = 4 version = 4
[[package]]
name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]] [[package]]
name = "blocker-core" name = "blocker-core"
version = "0.1.0" version = "0.1.0"
@@ -11,6 +17,7 @@ name = "blocker-daemon"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"blocker-core", "blocker-core",
"blocker-storage",
] ]
[[package]] [[package]]
@@ -27,6 +34,14 @@ dependencies = [
"blocker-core", "blocker-core",
] ]
[[package]]
name = "blocker-storage"
version = "0.1.0"
dependencies = [
"blocker-core",
"rusqlite",
]
[[package]] [[package]]
name = "blocker-tests" name = "blocker-tests"
version = "0.1.0" version = "0.1.0"
@@ -37,3 +52,104 @@ dependencies = [
[[package]] [[package]]
name = "blocker-ui" name = "blocker-ui"
version = "0.1.0" version = "0.1.0"
[[package]]
name = "cc"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040"
dependencies = [
"find-msvc-tools",
"shlex",
]
[[package]]
name = "fallible-iterator"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
[[package]]
name = "fallible-streaming-iterator"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]]
name = "find-msvc-tools"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"foldhash",
]
[[package]]
name = "hashlink"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"
dependencies = [
"hashbrown",
]
[[package]]
name = "libsqlite3-sys"
version = "0.35.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f"
dependencies = [
"cc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "pkg-config"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
[[package]]
name = "rusqlite"
version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f"
dependencies = [
"bitflags",
"fallible-iterator",
"fallible-streaming-iterator",
"hashlink",
"libsqlite3-sys",
"smallvec",
]
[[package]]
name = "shlex"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "smallvec"
version = "1.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e"
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
+1
View File
@@ -2,6 +2,7 @@
resolver = "2" resolver = "2"
members = [ members = [
"core", "core",
"storage",
"tests", "tests",
"desktop/daemon", "desktop/daemon",
"desktop/ui", "desktop/ui",
+6 -4
View File
@@ -16,7 +16,7 @@ enforces anything yet. No platform has a working build.
| Milestone | State | | Milestone | State |
|---|---| |---|---|
| M0 repo bootstrap | in progress | | M0 repo bootstrap | in progress |
| M1 core engine | engine, lock timers and safety guarantees written and tested; storage has no I/O yet | | M1 core engine | engine, lock timers and safety guarantees written and tested; SQLite persistence landed in `storage/` |
| M2-Linux | not started — daemon is a placeholder | | M2-Linux | not started — daemon is a placeholder |
| M3-Android | not started | | M3-Android | not started |
| M4 conditions | not started | | M4 conditions | not started |
@@ -74,6 +74,7 @@ summary as not-passes. A skip is never reported as a pass.
``` ```
core/ the decision engine — pure, no OS calls, no dependencies core/ the decision engine — pure, no OS calls, no dependencies
storage/ SQLite persistence (rusqlite, bundled), fail-open recovery
tests/ golden decision tables, and the engine purity check tests/ golden decision tables, and the engine purity check
desktop/daemon/ long-lived observer and enforcer (5 MB budget) desktop/daemon/ long-lived observer and enforcer (5 MB budget)
desktop/ui/ block-screen window, launched on demand desktop/ui/ block-screen window, launched on demand
@@ -106,9 +107,10 @@ scripts/ ci.sh, size-check.sh
| Daemon binary | ≤ 5 MB stripped | | Daemon binary | ≤ 5 MB stripped |
| APK size | ≤ 10 MB | | APK size | ≤ 10 MB |
Currently measured: **0.279 MB** stripped daemon — a placeholder that prints two Currently measured: **1.364 MB** stripped daemon, which includes bundled SQLite.
lines. The meaningful number comes in M2-Linux. Enforced in CI from the first Of the 5 MB budget that leaves 3.8 MB for D-Bus, X11 and a UI toolkit — a real
commit by `scripts/size-check.sh`. margin rather than a comfortable one. Enforced in CI from the first commit by
`scripts/size-check.sh`.
## Licence ## Licence
+39 -18
View File
@@ -214,6 +214,7 @@ pub fn evaluate(input: &EngineInput<'_>) -> Decision {
ScheduleMode::Blocklist => { ScheduleMode::Blocklist => {
for target in &schedule.targets { for target in &schedule.targets {
let reason = BlockReason { let reason = BlockReason {
target: target.clone(),
schedule_id: schedule.id.clone(), schedule_id: schedule.id.clone(),
schedule_name: schedule.name.clone(), schedule_name: schedule.name.clone(),
condition: active[0], condition: active[0],
@@ -245,6 +246,7 @@ pub fn evaluate(input: &EngineInput<'_>) -> Decision {
continue; continue;
} }
blocked_apps.push(BlockReason { blocked_apps.push(BlockReason {
target: Target::App(id.clone()),
schedule_id: schedule.id.clone(), schedule_id: schedule.id.clone(),
schedule_name: schedule.name.clone(), schedule_name: schedule.name.clone(),
condition: active[0], condition: active[0],
@@ -384,38 +386,57 @@ fn bucket_reset(now: CivilTime, window: &crate::model::UsageWindow) -> CivilTime
/// Next boundary of a time window: either its end today, or its start if the /// Next boundary of a time window: either its end today, or its start if the
/// window is not currently active. /// window is not currently active.
fn time_window_boundary(window: &crate::model::TimeWindow, now: CivilTime) -> Option<CivilTime> { fn time_window_boundary(window: &crate::model::TimeWindow, now: CivilTime) -> Option<CivilTime> {
// A window with equal bounds matches nothing, ever. It must therefore
// contribute no boundary: a timer that exists only to discover the window
// still does not match is a wakeup spent on nothing, against the section 5
// idle budget. The `.max(1)` above keeps a legitimately-current boundary in
// the future; this keeps an impossible one from existing at all.
if window.start == window.end {
return None;
}
let minute = minute_of_day(now); let minute = minute_of_day(now);
let today = weekday(now); let today = weekday(now);
if time_window_contains(window, now) { if time_window_contains(window, now) {
// Currently active: the end depends on whether we're in the early or // Currently active. Where the END lands depends on which half of an
// late part of an overnight window. // overnight window we are in.
let end_today = if window.is_overnight() && minute < window.end { //
// Early part — ends later today. // At 23:00 in a 22:00-06:00 window the end is 06:00 *tomorrow*, 420
window.end // minutes away — not 06:00 today, which is in the past. Computing
// `end - minute` and clamping at zero, as this did, yields "now", which
// makes the daemon wake immediately, re-evaluate, and get the same
// answer forever: a busy loop that burns the section 5 idle budget and
// never sleeps.
let minutes_until = if window.is_overnight() && minute >= window.start {
// Late part: crosses midnight.
(24 * 60) - i64::from(minute) + i64::from(window.end)
} else { } else {
window.end // Either a daytime window, or the early part of an overnight one.
i64::from(window.end) - i64::from(minute)
}; };
let minutes_until = i64::from(end_today) - i64::from(minute);
return Some(CivilTime { return Some(CivilTime {
epoch_minutes: now.epoch_minutes + minutes_until.max(0), epoch_minutes: now.epoch_minutes + minutes_until.max(1),
utc_offset_minutes: now.utc_offset_minutes, utc_offset_minutes: now.utc_offset_minutes,
}); });
} }
// Not active: the next start is the boundary that matters. // Not active: the next start is the boundary that matters.
//
// The day offset must be folded into the delta, not just used to test
// membership. Iterating day offsets and computing only `start - minute`
// wraps by 1440 without advancing the day, so an inactive weekday schedule
// on a Saturday would report Monday's start as 21 hours away — Sunday 09:00,
// a day that is not in the set. The block still fires, because the next
// evaluation recomputes it, but the daemon wakes a day early for nothing,
// which is exactly what the section 5 idle budget forbids.
let mut best: Option<i64> = None; let mut best: Option<i64> = None;
for day_offset in 0..=7 { for day_offset in 0..=7i64 {
let candidate_day = (today as i32 + day_offset) % 7; let candidate_day = ((today as i64 + day_offset) % 7) as u8;
if !window.days.contains(&(candidate_day as u8)) { if !window.days.contains(&candidate_day) {
continue; continue;
} }
let mut delta = i64::from(window.start) - i64::from(minute); let mut delta = day_offset * 24 * 60 + 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 { if delta < 0 {
delta += 24 * 60; delta += 24 * 60;
} }
@@ -436,7 +457,7 @@ fn time_window_boundary(window: &crate::model::TimeWindow, now: CivilTime) -> Op
/// Unlike [`condition_boundary`], which answers "when does a *firing* condition /// Unlike [`condition_boundary`], which answers "when does a *firing* condition
/// stop firing", this also covers a condition that is currently false — a time /// 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 /// window that has not started yet still has a start, and that start is exactly
/// when the daemon must wake up to begin blocking. /// when the daemon must wake to begin blocking.
/// ///
/// Returns `None` when nothing about the schedule is time-dependent, which is /// 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, /// the case for Wi-Fi and location schedules: only an OS event can change them,
+30 -5
View File
@@ -123,12 +123,23 @@ pub struct Schedule {
/// Why a target is currently blocked, and until when. /// Why a target is currently blocked, and until when.
/// ///
/// Section 1.3 requires the block screen to say which schedule blocked and /// Section 1.3 requires the block screen to say which schedule blocked and when
/// when it ends, so the engine computes that here and the UI never re-derives /// it ends, so the engine computes that here and the UI never re-derives it.
/// it. Keeping the explanation in the decision is what stops three platforms /// Keeping the explanation in the decision is what stops three platforms from
/// from telling the user three different stories. /// telling the user three different stories.
#[derive(Debug, Clone, PartialEq, Eq)] ///
/// The `target` field is not decoration. A schedule may name twenty targets and
/// they do not all become blocked: some are paused, some belong to a condition
/// that is not currently active, and some are on the never-block list. Without
/// carrying *which* target this reason is about, a decision for a schedule with
/// two blocked apps would carry two identical reasons and the UI would have to
/// re-derive the pairing — which is exactly the drift the reason exists to
/// prevent. Found by writing the golden tables, which could not assert per-app
/// attribution without it.
#[derive(Debug, Clone, PartialEq)]
pub struct BlockReason { pub struct BlockReason {
/// The specific target being blocked, not merely the schedule.
pub target: Target,
pub schedule_id: String, pub schedule_id: String,
pub schedule_name: String, pub schedule_name: String,
/// The specific condition that fired, named for display. /// The specific condition that fired, named for display.
@@ -138,6 +149,20 @@ pub struct BlockReason {
pub ends_at: Option<crate::engine::CivilTime>, pub ends_at: Option<crate::engine::CivilTime>,
} }
impl BlockReason {
/// A stable, comparable key for the target this reason is about.
///
/// Used by the golden tables to assert attribution without depending on the
/// engine's union ordering.
pub fn app_key(&self) -> String {
match &self.target {
Target::App(id) => id.clone(),
Target::Website(d) => format!("website:{d}"),
Target::Keyword(k) => format!("keyword:{k}"),
}
}
}
/// A rejection of a schedule that does not satisfy section 1.2. /// A rejection of a schedule that does not satisfy section 1.2.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum ValidationError { pub enum ValidationError {
+1
View File
@@ -17,3 +17,4 @@ path = "src/main.rs"
# (zbus for D-Bus, x11rb for X11) arrive in M2-Linux, each with an ADR entry. # (zbus for D-Bus, x11rb for X11) arrive in M2-Linux, each with an ADR entry.
[dependencies] [dependencies]
blocker-core = { path = "../../core" } blocker-core = { path = "../../core" }
blocker-storage = { path = "../../storage" }
+71 -11
View File
@@ -2,16 +2,16 @@
//! //!
//! The daemon observes the OS and enforces the decisions `blocker-core` makes. //! The daemon observes the OS and enforces the decisions `blocker-core` makes.
//! It contains **no decision logic of its own** (CLAUDE.md section 2): if the //! It contains **no decision logic of its own** (CLAUDE.md section 2): if the
//! daemon ever decides that something should be blocked, the Android and //! daemon ever decides that something should be blocked, the Android and desktop
//! desktop builds will drift and the golden tables stop being a parity contract. //! builds will drift and the golden tables stop being a parity contract.
//! //!
//! # Shape //! # Shape
//! //!
//! Two processes, deliberately: //! Two processes, deliberately:
//! //!
//! - the **daemon** — small, long-lived, stays resident, holds the block window //! - the **daemon** — small, long-lived, holds the block window machinery and
//! machinery and the engine connection. This is the binary with the 5 MB //! the engine connection. This is the binary with the 5 MB stripped budget
//! stripped budget (section 5). //! (section 5).
//! - the **UI** (`blocker-ui`) — a separate process launched to show a block //! - the **UI** (`blocker-ui`) — a separate process launched to show a block
//! screen, which exits when the block lifts. //! screen, which exits when the block lifts.
//! //!
@@ -20,14 +20,74 @@
//! //!
//! # Event loop //! # Event loop
//! //!
//! M2-Linux replaces this placeholder with a loop that blocks on exactly three //! M2-Linux replaces the startup path below with a loop that blocks on exactly
//! things: a focus-change event from the active //! three things: a focus-change event from the active
//! [`ForegroundWatcher`](../../blocker_core/index.html) backend, the //! [`ForegroundWatcher`](../../blocker_platform_linux/index.html) backend, the
//! `next_wakeup` instant from the engine, and IPC from the UI. There is no //! `next_wakeup` instant from the engine, and IPC from the UI. There is no
//! polling tick anywhere — an idle daemon takes zero wakeups, which is how the //! polling tick anywhere — an idle daemon takes zero wakeups, which is how the
//! section 5 idle-CPU budget is met. //! section 5 idle-CPU budget is met.
//!
//! # Where the database lives
//!
//! Under the XDG data directory, never beside the binary. The binary may be
//! installed read-only in `/usr/bin`, while the database must be writable and
//! must survive an upgrade.
fn main() { use std::path::PathBuf;
println!("blocker-daemon {} (M0 placeholder)", env!("CARGO_PKG_VERSION")); use std::process::ExitCode;
println!("enforcement lands in M2-Linux; see docs/DECISIONS.md for the ADR log");
/// `~/.local/share/blocker/blocker.db`, honouring `XDG_DATA_HOME`.
fn database_path() -> PathBuf {
let base = std::env::var_os("XDG_DATA_HOME")
.map(PathBuf::from)
.filter(|p| p.is_absolute())
.unwrap_or_else(|| {
let home =
std::env::var_os("HOME").map(PathBuf::from).unwrap_or_else(|| PathBuf::from("."));
home.join(".local/share")
});
base.join("blocker").join("blocker.db")
}
fn main() -> ExitCode {
let path = database_path();
// Section 1.5.4: a corrupt or unusable database must never leave the device
// unusable. `Store::open` handles corruption by moving the damaged file
// aside and starting clean, so what reaches the `Err` arm is genuinely fatal
// — an unwritable directory, say. Even then we exit with a message rather
// than panicking: a daemon that crashes on startup is worse than one that
// reports the problem and stops.
let store = match blocker_storage::Store::open(&path) {
Ok(store) => store,
Err(e) => {
eprintln!("blocker-daemon: cannot open storage at {}: {e}", path.display());
eprintln!("blocking is disabled until this is fixed; see docs/LIMITATIONS.md");
return ExitCode::FAILURE;
}
};
if store.recovered_from_corruption() {
// Logged, not swallowed: section 1.5.4 wants the recovery recorded.
eprintln!(
"blocker-daemon: database was corrupt and has been moved to {}.corrupt; \
starting clean, so no schedules are active until you re-add them",
path.display()
);
}
let schedules = match store.load_schedules() {
Ok(s) => s,
Err(e) => {
eprintln!("blocker-daemon: cannot read schedules: {e}");
return ExitCode::FAILURE;
}
};
println!("blocker-daemon {} (M0/M1)", env!("CARGO_PKG_VERSION"));
println!("database: {}", path.display());
println!("schedules: {}", schedules.len());
println!("enforcement lands in M2-Linux; see docs/DECISIONS.md for the ADR log");
ExitCode::SUCCESS
} }
+10 -3
View File
@@ -182,10 +182,17 @@ APK size cost is unacceptable, which is a measurement before it is a decision.
| `usage` | foreground-time and launch-count accumulation into daily and hourly buckets | | `usage` | foreground-time and launch-count accumulation into daily and hourly buckets |
| `lock` | lock levels, the friction ladder's timing, monotonic anti-tamper | | `lock` | lock levels, the friction ladder's timing, monotonic anti-tamper |
| `never_block` | the section 1.5.4 safety list | | `never_block` | the section 1.5.4 safety list |
| `storage` | the SQLite schema and its fail-open recovery policy | | `storage` | *(separate crate: `blocker-storage`)* the SQLite schema and its fail-open recovery policy |
`storage` has no I/O behind it yet — see `LIMITATIONS.md`. The schema is written `blocker-storage` is a sibling crate, not a submodule. It depends on
and reviewed as text so it can be reviewed before it is compiled. `blocker-core`; `blocker-core` does not depend on it, and depends on nothing at
all. That direction is what lets `tests/purity.rs` keep checking "blocker-core has
zero dependencies" — an automatic proof that the engine cannot do I/O — instead of
the weaker and easily-routed-around alternative of grepping the source for
`rusqlite`.
Both crates occupy the same layer of the diagram above: storage is shared by every
platform and contains no platform code.
## Where the platform crates start and stop ## Where the platform crates start and stop
+61 -22
View File
@@ -25,7 +25,7 @@ which parts to trust.
| [1](#adr-1) | Rust for core and desktop, Kotlin for the Android shell | Accepted | | [1](#adr-1) | Rust for core and desktop, Kotlin for the Android shell | Accepted |
| [2](#adr-2) | One shared core; the engine is a pure function | Accepted | | [2](#adr-2) | One shared core; the engine is a pure function | Accepted |
| [3](#adr-3) | Event-driven with `next_wakeup`, never polling | Accepted | | [3](#adr-3) | Event-driven with `next_wakeup`, never polling | Accepted |
| [4](#adr-4) | SQLite via rusqlite, bundled | Proposed | | [4](#adr-4) | SQLite via rusqlite, in its own crate | Accepted, one trade-off open |
| [5](#adr-5) | uniffi for core-to-Kotlin bindings | Proposed | | [5](#adr-5) | uniffi for core-to-Kotlin bindings | Proposed |
| [6](#adr-6) | Desktop UI toolkit — Slint vs egui | **Proposed, needs owner** | | [6](#adr-6) | Desktop UI toolkit — Slint vs egui | **Proposed, needs owner** |
| [7](#adr-7) | Desktop website blocking mechanism | **Accepted — owner chose extension** | | [7](#adr-7) | Desktop website blocking mechanism | **Accepted — owner chose extension** |
@@ -37,7 +37,7 @@ which parts to trust.
| [13](#adr-13) | Zero external dependencies in M0 | Accepted | | [13](#adr-13) | Zero external dependencies in M0 | Accepted |
| [14](#adr-14) | Linux enforcement backend order: KWin, X11, process | Accepted | | [14](#adr-14) | Linux enforcement backend order: KWin, X11, process | Accepted |
| [15](#adr-15) | Linux-first milestone order (amends section 8) | Accepted | | [15](#adr-15) | Linux-first milestone order (amends section 8) | Accepted |
| [16](#adr-16) | Golden tables are JSON, not TOML | Proposed | | [16](#adr-16) | Golden tables are Rust, not JSON | Accepted |
| [17](#adr-17) | Licence not yet chosen | **Open — owner decision** | | [17](#adr-17) | Licence not yet chosen | **Open — owner decision** |
--- ---
@@ -108,24 +108,46 @@ it is supposed to start blocking.
--- ---
<a id="adr-4"></a> <a id="adr-4"></a>
## ADR-4: SQLite via rusqlite, bundled ## ADR-4: SQLite via rusqlite
**Status:** Proposed — dependency not yet added **Status:** Accepted and implemented, with one trade-off still open
**Context.** Section 3 specifies `rusqlite` with bundled SQLite. Section 1.5.3 **Context.** Section 3 specifies `rusqlite` with bundled SQLite. Section 1.5.3
requires lock and Strict state to live in the core database rather than in requires lock and Strict state to live in the core database rather than in
editable plaintext config. editable plaintext config.
**Decision.** `rusqlite` with `bundled`, storing schedules, lock state, and usage **Decision.** `rusqlite` with `bundled`, in a **separate `blocker-storage` crate**
counters in one file. that depends on `blocker-core`, never the reverse.
**Consequences.** The schema is written and reviewed in **Why the separate crate.** The purity contract says the engine does no I/O, and
`core/src/storage.rs` before the crate is added, so it can be reviewed as text. `tests/purity.rs` enforces that mechanically by failing if `blocker-core` has any
Bundling compiles SQLite from source, which costs build time and binary size but dependency at all. Putting `rusqlite` in `blocker-core` would have forced that
removes a system dependency — a real consideration on the machines this ships to. check to degrade into a source-text grep — something a careful developer can route
around, and something nobody reads in six months. Splitting keeps the check
automatic.
The binary-size cost has not yet been measured against the 5 MB budget. That is Both crates sit in the same layer of the section 2 diagram: storage is shared by
an open item, not a settled fact. every platform and contains no platform code. This is a refinement that makes the
guarantee checkable, not an architectural departure.
**Measured consequences.**
- Bundled SQLite costs **~1.1 MB** of the 5 MB stripped daemon budget (1.364 MB
total, from 0.279 MB without it). That leaves 3.8 MB for D-Bus, X11 and a UI
toolkit — a real margin, not a comfortable one.
- The build compiles SQLite from C source, which is a noticeable first-build cost.
- System-linking would reclaim most of the 1.1 MB at the price of a runtime
dependency on the host's libsqlite3, and a schema-format mismatch that only
appears on a user's machine.
**Still open:** bundled vs system-linked. Measured cost is now known; the decision
is the owner's.
**Schema notes.** Relational (`schedule_target`, `schedule_condition`) rather than
JSON blobs in a text column, because target rows are queried by target and an
index on a JSON string is not a plan. Serialisation is hand-written in
`storage/src/codec.rs` — `serde` is not a section 3 dependency, and section 0 rule
3 requires asking before adding one (ADR-13).
--- ---
@@ -409,22 +431,39 @@ machine.
--- ---
<a id="adr-16"></a> <a id="adr-16"></a>
## ADR-16: Golden tables are JSON, not TOML ## ADR-16: Golden tables are Rust, not JSON
**Status:** Proposed — depends on ADR-4 and the serde decision **Status:** Accepted and implemented. The original JSON rationale turned out to
be wrong, and is recorded below rather than quietly replaced.
**Context.** Section 7 requires golden decision tables as the cross-platform **Context.** Section 7 requires golden decision tables as the cross-platform
parity contract. The format must be serialisable and diffable in review. parity contract.
**Decision.** JSON, one file per concern, under `tests/golden/`. **What was originally decided, and why it was wrong.** JSON, on the reasoning
that `serde_json` would already be in the dependency graph. That was falsified in
practice: `rusqlite` does **not** pull `serde` by default, and `uniffi` had not
landed, so nothing in the graph provided it. `serde` is not a section 3
dependency, and section 0 rule 3 requires asking before adding one.
**Rationale.** `serde_json` is already in the dependency graph via `rusqlite` and **Decision.** A builder DSL in Rust. `tests/src/golden.rs` holds the format and
`uniffi`, so TOML would add a crate for no gain, and section 3 makes every crate harness, `tests/src/tables.rs` the cases, `tests/src/coverage.rs` the coverage
justify its cost. JSON is also what a review diff reads most naturally. bookkeeping, `tests/src/run.rs` the runner.
**Not yet decided.** The parse layer needs serde, which is a dependency decision **Rationale.** The same declarations, with two properties JSON cannot offer: the
requiring the owner's sign-off. The format itself is written down in compiler rejects a typo in a field name, and a wrong variant is a type error
`tests/src/golden.rs` now; the parse arrives in M1 with ADR-4. rather than a test that quietly asserts the wrong thing. For a document whose
entire job is to be a contract, compile-time checking is worth more than
diffability.
Converting to JSON later, if serde is approved, is mechanical — only
`tests/src/golden.rs` changes and the case bodies become literals instead of
builder calls.
**Also settled here.** Three scenarios are deliberately *not* table-shaped.
Clock tampering and the 24-hour cap are about the passage of time and a bound
across lock levels, not a decision at an instant, so they live as code fixtures
in `tests/src/purity.rs`. `coverage::Location` records which is which, so
"covered" can never quietly mean "listed but not tested".
--- ---
+13 -7
View File
@@ -177,17 +177,23 @@ not mistaken for platform facts.
- **`ProcessBackend` is not implemented.** ADR-14 lists three backends; the KWin - **`ProcessBackend` is not implemented.** ADR-14 lists three backends; the KWin
and X11 paths land in M2-Linux, and the process fallback is weaker than the and X11 paths land in M2-Linux, and the process fallback is weaker than the
spec assumes because of the `/proc/connectors` gap above. spec assumes because of the `/proc/connectors` gap above.
- **`rusqlite` has not been added.** The schema is written and reviewed in - **`rusqlite` vs system-linked SQLite is unresolved** (ADR-4). Bundled is built
`core/src/storage.rs` (ADR-4) but storage has no I/O behind it yet, so nothing and costs ~1.1 MB of the 5 MB daemon budget. System-linking would reclaim most
persists. A restart loses all state. of that but adds a runtime dependency on the host's libsqlite3.
- **Lock state is not yet persisted or enforced.** `core/src/lock.rs` has the - **Lock state is not yet persisted or enforced.** `core/src/lock.rs` has the
timers, the escalation, and the wall-clock correction, all tested. The state timers, the escalation, and the wall-clock correction, all tested. The state
machine is not yet driven from a UI, and `LockState::evaluate` currently returns machine is not yet driven from a UI, and `LockState::evaluate` currently returns
the Normal level unconditionally. the Normal level unconditionally.
- **The golden tables cannot be parsed.** The format is specified in - **DST coverage is partial, and the boundary is stated.** `CivilTime` is an
`tests/src/golden.rs`; parsing needs serde, which is a dependency decision epoch value plus a UTC offset, with no IANA timezone database behind it — the
awaiting sign-off (ADR-13, ADR-16). The behavioural coverage that exists today `time` crate is not a section 3 dependency (ADR-16). The golden tables pin the
is hand-written unit tests in the same spirit. observable rule, that a fixed local window stays fixed as the offset moves,
including across a DST transition. What they **cannot** express is a local time
that does not exist because the clock sprang forward, or one that is ambiguous
because it fell back. Those are cases section 7 lists that are not yet
covered. This is the strongest argument for a timezone-aware time type.
- **`LockState` still always evaluates to `Normal`** — persistence works, but no
UI drives the lock state machine yet.
- **`Condition::UsageLimit` and `LaunchCount` sum across all targets** rather - **`Condition::UsageLimit` and `LaunchCount` sum across all targets** rather
than the schedule's own target set, until targets carry usage keys. than the schedule's own target set, until targets carry usage keys.
- **Windows is entirely unimplemented** and cannot be compiled or tested on the - **Windows is entirely unimplemented** and cannot be compiled or tested on the
+14 -5
View File
@@ -29,17 +29,26 @@ Measured on: Ubuntu 26.04 LTS, KDE Plasma 6.6.6, 6 CPUs. Toolchain: rustc 1.98.1
| Metric | Target | Measured | When | | Metric | Target | Measured | When |
|---|---|---|---| |---|---|---|---|
| Daemon binary (stripped, release) | ≤ 5 MB | **0.279 MB** | 2026-10-01, M0 | | Daemon binary (stripped, release) | ≤ 5 MB | **1.364 MB** | 2026-10-01, M1 |
| Block latency | ≤ 150 ms | not measured | — | | Block latency | ≤ 150 ms | not measured | — |
| Idle CPU | ~0% | not measured | — | | Idle CPU | ~0% | not measured | — |
| Idle RAM (daemon) | ≤ 15 MB RSS | not measured | — | | Idle RAM (daemon) | ≤ 15 MB RSS | not measured | — |
| Idle wakeups | ≤ 1/min | not measured | — | | Idle wakeups | ≤ 1/min | not measured | — |
| APK size | ≤ 10 MB | not measured | — | | APK size | ≤ 10 MB | not measured | — |
The binary figure is real but nearly meaningless right now: the daemon is two **What the 1.364 MB contains, and why it matters.** That figure is with bundled
`println!` lines. The meaningful measurement comes in M2-Linux once enforcement SQLite linked in and the daemon actually opening a database — not the 0.279 MB
exists, and the real risk is at that point — D-Bus, X11, and a UI toolkit are placeholder from M0, which was two `println!` lines and excluded SQLite entirely
where 5 MB would actually be threatened. through dead-code elimination.
So the honest read is: **bundled SQLite costs roughly 1.1 MB of a 5 MB budget.**
That leaves 3.8 MB for D-Bus, X11, and the UI toolkit. It is a real constraint
rather than a comfortable margin, and it is the first number that makes the
section 5 budget feel like a budget instead of a formality.
It also makes ADR-4 (rusqlite bundled vs system-linked) a genuine trade-off rather
than a settled one: system-linking would recover most of that 1.1 MB at the cost
of a runtime dependency on the host's libsqlite3. Recorded as an open question.
## How to measure ## How to measure
+35
View File
@@ -0,0 +1,35 @@
[package]
name = "blocker-storage"
version = "0.1.0"
edition = "2021"
description = "SQLite persistence for Blocker: schedules, lock state, and usage counters."
license = "UNLICENSED"
[dependencies]
blocker-core = { path = "../core" }
# rusqlite with bundled SQLite, per CLAUDE.md section 3. Bundled rather than
# system-linked because the app ships to machines where libsqlite3 may be absent
# or a different major version, and a storage-format mismatch that only shows up
# on a user's machine is not a failure mode worth carrying.
#
# THIS CRATE IS NOT DEPENDENT ON BY blocker-core.
#
# That is the entire reason this crate exists separately. CLAUDE.md section 2
# requires the engine to be a pure function with no I/O, and the strongest
# mechanical check for that is "blocker-core has zero dependencies" — a test
# that reads Cargo.lock and fails on any entry. Keeping storage in its own crate
# lets that test stay strict and automatic, instead of degrading into a source
# grep that someone can route around.
#
# Both crates sit in the same layer: storage is shared by every platform and
# contains no platform code, so this is a refinement of the section 2 diagram,
# not a departure from it.
rusqlite = { version = "0.37", features = ["bundled"] }
[lints]
workspace = true
[lib]
name = "blocker_storage"
path = "src/lib.rs"
+347
View File
@@ -0,0 +1,347 @@
//! Encoding between model types and their database representation.
//!
//! Split out from the SQL so the tricky part — turning a `Condition` into
//! nullable columns and back without silently losing a field — is testable
//! without a database, and so a decode failure has somewhere precise to point.
//!
//! # Why this is hand-written rather than serde
//!
//! `serde` is not a CLAUDE.md section 3 dependency, and section 0 rule 3 requires
//! stopping to ask before adding one. The encoding is a few hundred lines of
//! mechanical match arms, which is a real cost — and it is the cost of not adding
//! a dependency without permission. If serde is approved later, this module is the
//! only thing that changes.
//!
//! # Decoding never panics
//!
//! Every decode failure returns [`DecodeError`]. A database written by a newer
//! version, or corrupted by hand, must produce a diagnosable error rather than a
//! panic: this is enforcement code, and section 1.5.4 requires failing open.
use blocker_core::model::{
Condition, DayOfWeek, MinuteOfDay, Schedule, ScheduleMode, Target, TimeWindow, UsageWindow,
};
/// Why stored bytes could not become a model value.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DecodeError {
/// A `mode` column held something other than the two known values.
UnknownMode(String),
/// A `level` column held something other than the three known values.
UnknownLevel(String),
/// A `kind` column held something unrecognised.
UnknownKind(String),
/// A target string was empty.
EmptyTarget,
/// A `start`/`end` time was not `HH:MM`, or was outside 0..1440.
BadTime(String),
/// A time of day fell outside 0..1440 minutes.
MinuteOutOfRange(i64),
/// A `days` list was malformed, or held a value outside 0..=6.
BadDays(String),
/// A boolean column was neither 0 nor 1.
BadFlag(i64),
/// A numeric column was negative where only a count is meaningful.
NegativeCount(i64),
}
impl std::fmt::Display for DecodeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::UnknownMode(m) => write!(f, "unknown schedule mode {m:?}"),
Self::UnknownLevel(l) => write!(f, "unknown lock level {l:?}"),
Self::UnknownKind(k) => write!(f, "unknown condition kind {k:?}"),
Self::EmptyTarget => write!(f, "target value is empty"),
Self::BadTime(t) => write!(f, "time {t:?} is not HH:MM"),
Self::MinuteOutOfRange(m) => write!(f, "minute {m} is outside 0..1440"),
Self::BadDays(d) => write!(f, "day list {d:?} is malformed"),
Self::BadFlag(v) => write!(f, "expected 0 or 1, found {v}"),
Self::NegativeCount(v) => write!(f, "expected a non-negative count, found {v}"),
}
}
}
impl std::error::Error for DecodeError {}
/// `ScheduleMode` as stored.
pub fn mode_to_str(mode: ScheduleMode) -> &'static str {
match mode {
ScheduleMode::Blocklist => "Blocklist",
ScheduleMode::Allowlist => "Allowlist",
}
}
pub fn mode_from_str(raw: &str) -> Result<ScheduleMode, DecodeError> {
match raw {
"Blocklist" => Ok(ScheduleMode::Blocklist),
"Allowlist" => Ok(ScheduleMode::Allowlist),
other => Err(DecodeError::UnknownMode(other.to_owned())),
}
}
/// `LockLevel` as stored.
pub fn level_to_str(level: blocker_core::lock::LockLevel) -> &'static str {
match level {
blocker_core::lock::LockLevel::Normal => "Normal",
blocker_core::lock::LockLevel::Strict => "Strict",
blocker_core::lock::LockLevel::Stubborn => "Stubborn",
}
}
pub fn level_from_str(raw: &str) -> Result<blocker_core::lock::LockLevel, DecodeError> {
use blocker_core::lock::LockLevel;
match raw {
"Normal" => Ok(LockLevel::Normal),
"Strict" => Ok(LockLevel::Strict),
"Stubborn" => Ok(LockLevel::Stubborn),
other => Err(DecodeError::UnknownLevel(other.to_owned())),
}
}
/// A decoded target.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TargetRow {
/// Position within the schedule, preserving the user's ordering.
pub ordinal: i64,
/// Discriminator: `app`, `website`, or `keyword`.
pub kind: String,
/// The identifier itself.
pub value: String,
}
/// Targets in database form.
pub fn encode_targets(targets: &[Target]) -> Vec<TargetRow> {
targets
.iter()
.enumerate()
.map(|(ordinal, t)| {
let (kind, value) = match t {
Target::App(v) => ("app", v.clone()),
Target::Website(v) => ("website", v.clone()),
Target::Keyword(v) => ("keyword", v.clone()),
};
TargetRow {
ordinal: i64::try_from(ordinal).unwrap_or(i64::MAX),
kind: kind.to_owned(),
value,
}
})
.collect()
}
pub fn decode_targets(rows: &[TargetRow]) -> Result<Vec<Target>, DecodeError> {
let mut out: Vec<(i64, Target)> = Vec::with_capacity(rows.len());
for row in rows {
if row.value.is_empty() {
return Err(DecodeError::EmptyTarget);
}
let target = match row.kind.as_str() {
"app" => Target::App(row.value.clone()),
"website" => Target::Website(row.value.clone()),
"keyword" => Target::Keyword(row.value.clone()),
other => return Err(DecodeError::UnknownKind(other.to_owned())),
};
out.push((row.ordinal, target));
}
// Ordinals come from the database unordered; sorting restores user order,
// which matters for a list the user arranged deliberately.
out.sort_by_key(|(ordinal, _)| *ordinal);
Ok(out.into_iter().map(|(_, t)| t).collect())
}
/// A decoded condition. Column-shaped rather than model-shaped, so a NULL in the
/// wrong place is caught here rather than becoming a silently defaulted
/// condition.
#[derive(Debug, Clone, PartialEq)]
pub struct ConditionRow {
pub ordinal: i64,
pub kind: String,
/// Comma-separated weekdays, Monday = 0.
pub days: Option<String>,
pub start_min: Option<i64>,
pub end_min: Option<i64>,
pub limit_value: Option<i64>,
pub window: Option<String>,
pub ssid: Option<String>,
pub latitude: Option<f64>,
pub longitude: Option<f64>,
pub radius_m: Option<i64>,
pub outside: Option<i64>,
}
pub fn encode_conditions(conditions: &[Condition]) -> Vec<ConditionRow> {
conditions
.iter()
.enumerate()
.map(|(ordinal, c)| {
let mut row = ConditionRow {
ordinal: i64::try_from(ordinal).unwrap_or(i64::MAX),
kind: String::new(),
days: None,
start_min: None,
end_min: None,
limit_value: None,
window: None,
ssid: None,
latitude: None,
longitude: None,
radius_m: None,
outside: None,
};
match c {
Condition::Time(w) => {
row.kind = "Time".to_owned();
row.days = Some(w.days.iter().map(u8::to_string).collect::<Vec<_>>().join(","));
row.start_min = Some(i64::from(w.start));
row.end_min = Some(i64::from(w.end));
}
Condition::UsageLimit { limit_minutes, window } => {
row.kind = "UsageLimit".to_owned();
row.limit_value = Some(i64::from(*limit_minutes));
row.window = Some(window_to_str(*window).to_owned());
}
Condition::LaunchCount { limit, window } => {
row.kind = "LaunchCount".to_owned();
row.limit_value = Some(i64::from(*limit));
row.window = Some(window_to_str(*window).to_owned());
}
Condition::WifiSsid(ssid) => {
row.kind = "WifiSsid".to_owned();
row.ssid = Some(ssid.clone());
}
Condition::Location { latitude, longitude, radius_meters, block_when_outside } => {
row.kind = "Location".to_owned();
row.latitude = Some(*latitude);
row.longitude = Some(*longitude);
row.radius_m = Some(i64::from(*radius_meters));
row.outside = Some(i64::from(*block_when_outside));
}
}
row
})
.collect()
}
pub fn decode_conditions(rows: &[ConditionRow]) -> Result<Vec<Condition>, DecodeError> {
let mut out: Vec<(i64, Condition)> = Vec::with_capacity(rows.len());
for row in rows {
out.push((row.ordinal, decode_condition(row)?));
}
out.sort_by_key(|(ordinal, _)| *ordinal);
Ok(out.into_iter().map(|(_, c)| c).collect())
}
fn decode_condition(row: &ConditionRow) -> Result<Condition, DecodeError> {
match row.kind.as_str() {
"Time" => {
let days = row.days.as_deref().ok_or(DecodeError::BadDays("<null>".into()))?;
let start = require_minute(row.start_min)?;
let end = require_minute(row.end_min)?;
Ok(Condition::Time(TimeWindow { days: decode_days(days)?, start, end }))
}
"UsageLimit" => Ok(Condition::UsageLimit {
limit_minutes: require_count(row.limit_value)?,
window: window_from_str(row.window.as_deref())?,
}),
"LaunchCount" => Ok(Condition::LaunchCount {
limit: require_count(row.limit_value)?,
window: window_from_str(row.window.as_deref())?,
}),
"WifiSsid" => Ok(Condition::WifiSsid(row.ssid.clone().ok_or(DecodeError::EmptyTarget)?)),
"Location" => Ok(Condition::Location {
latitude: row.latitude.ok_or(DecodeError::NegativeCount(-1))?,
longitude: row.longitude.ok_or(DecodeError::NegativeCount(-1))?,
radius_meters: require_count(row.radius_m)?,
block_when_outside: decode_flag(row.outside.unwrap_or(0))?,
}),
other => Err(DecodeError::UnknownKind(other.to_owned())),
}
}
fn require_minute(value: Option<i64>) -> Result<MinuteOfDay, DecodeError> {
let v = value.ok_or(DecodeError::MinuteOutOfRange(-1))?;
u16::try_from(v).ok().filter(|m| *m < 1440).ok_or(DecodeError::MinuteOutOfRange(v))
}
fn require_count(value: Option<i64>) -> Result<u32, DecodeError> {
let v = value.ok_or(DecodeError::NegativeCount(-1))?;
u32::try_from(v).map_err(|_| DecodeError::NegativeCount(v))
}
fn decode_flag(value: i64) -> Result<bool, DecodeError> {
match value {
0 => Ok(false),
1 => Ok(true),
other => Err(DecodeError::BadFlag(other)),
}
}
fn window_to_str(window: UsageWindow) -> &'static str {
match window {
UsageWindow::Daily => "Daily",
UsageWindow::Hourly => "Hourly",
}
}
fn window_from_str(raw: Option<&str>) -> Result<UsageWindow, DecodeError> {
match raw {
Some("Daily") => Ok(UsageWindow::Daily),
Some("Hourly") => Ok(UsageWindow::Hourly),
_ => Err(DecodeError::UnknownKind(format!("usage window {raw:?}"))),
}
}
fn decode_days(raw: &str) -> Result<Vec<DayOfWeek>, DecodeError> {
if raw.is_empty() {
return Ok(Vec::new());
}
raw.split(',')
.map(|part| {
let trimmed = part.trim();
trimmed
.parse::<u8>()
.ok()
.filter(|d| *d < 7)
.ok_or_else(|| DecodeError::BadDays(raw.to_owned()))
})
.collect()
}
/// `HH:MM` to minutes since midnight. Used by the golden-table loader, which
/// reads times as strings.
pub fn parse_hhmm(raw: &str) -> Result<MinuteOfDay, DecodeError> {
let (h, m) = raw.split_once(':').ok_or_else(|| DecodeError::BadTime(raw.to_owned()))?;
let h: u16 = h.trim().parse().map_err(|_| DecodeError::BadTime(raw.to_owned()))?;
let m: u16 = m.trim().parse().map_err(|_| DecodeError::BadTime(raw.to_owned()))?;
if m >= 60 || h >= 24 {
return Err(DecodeError::BadTime(raw.to_owned()));
}
Ok(h * 60 + m)
}
/// Reassemble a schedule from its header and its decoded children.
///
/// Kept here rather than in `lib.rs` so the ordering contract — children sorted
/// by ordinal, attached to the right parent — is tested in one place.
pub fn decode_schedule(
id: &str,
name: &str,
icon: Option<&str>,
mode: &str,
targets: &[TargetRow],
conditions: &[ConditionRow],
paused: bool,
) -> Result<Schedule, DecodeError> {
Ok(Schedule {
id: id.to_owned(),
name: name.to_owned(),
icon: icon.map(str::to_owned),
mode: mode_from_str(mode)?,
targets: decode_targets(targets)?,
conditions: decode_conditions(conditions)?,
paused,
})
}
#[cfg(test)]
mod tests;
+201
View File
@@ -0,0 +1,201 @@
//! Tests for the storage encoding layer.
//!
//! These need no database: the codec is where a silently-dropped field would
//! live, and a round-trip test here is far cheaper to run and to read than the
//! same assertion through SQLite.
#![allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
reason = "test fixtures: a failing assertion or a broken test is not a runtime \
condition to handle gracefully"
)]
use super::*;
use blocker_core::model::{Condition, Schedule, ScheduleMode, Target, TimeWindow, UsageWindow};
/// Every condition shape must survive encode -> decode unchanged.
///
/// A field that is written but not read back is invisible in production and
/// catastrophic here: the schedule quietly stops working the next time the app
/// restarts.
#[test]
fn every_condition_round_trips() {
let cases = vec![
Condition::Time(TimeWindow { days: vec![0, 1, 2, 3, 4], start: 9 * 60, end: 17 * 60 }),
// Overnight: start > end must not be normalised or dropped on the way
// through the database. This is the representation the whole overnight
// rule depends on.
Condition::Time(TimeWindow { days: vec![6], start: 22 * 60, end: 6 * 60 }),
Condition::UsageLimit { limit_minutes: 90, window: UsageWindow::Daily },
Condition::LaunchCount { limit: 5, window: UsageWindow::Hourly },
Condition::WifiSsid("CoffeeShop".into()),
Condition::Location {
latitude: 52.52,
longitude: 13.405,
radius_meters: 500,
block_when_outside: true,
},
];
for original in cases {
let rows = encode_conditions(std::slice::from_ref(&original));
let decoded = decode_conditions(&rows).expect("decode must succeed");
assert_eq!(decoded, vec![original], "round trip changed the condition");
}
}
/// A schedule's ordinal order must survive, since the user arranges targets
/// deliberately and the block screen shows them in that order.
#[test]
fn target_order_is_preserved() {
let targets = vec![
Target::App("b.app".into()),
Target::Website("example.com".into()),
Target::Keyword("casino".into()),
Target::App("a.app".into()),
];
let mut rows = encode_targets(&targets);
// Simulate the database returning rows in an arbitrary order.
rows.reverse();
assert_eq!(decode_targets(&rows).unwrap(), targets);
}
/// Unknown discriminators must error, not default.
///
/// Defaulting here would turn a typo or a newer-version row into a schedule
/// that silently blocks the wrong thing.
#[test]
fn unknown_kinds_are_errors_not_defaults() {
let bad_target =
vec![TargetRow { ordinal: 0, kind: "applicaton".into(), value: "com.example".into() }];
assert_eq!(decode_targets(&bad_target), Err(DecodeError::UnknownKind("applicaton".into())));
let bad_condition = vec![ConditionRow {
ordinal: 0,
kind: "Timey".into(),
days: None,
start_min: None,
end_min: None,
limit_value: None,
window: None,
ssid: None,
latitude: None,
longitude: None,
radius_m: None,
outside: None,
}];
assert_eq!(decode_conditions(&bad_condition), Err(DecodeError::UnknownKind("Timey".into())));
}
/// A time of day outside 0..1440 must be rejected rather than wrapping.
///
/// A wrapped minute would produce a window that never matches, or one that
/// always matches — both look like "the app ignores my schedule".
#[test]
fn out_of_range_minutes_are_rejected() {
let bad = ConditionRow {
ordinal: 0,
kind: "Time".into(),
days: Some("0".into()),
start_min: Some(-60),
end_min: Some(1020),
limit_value: None,
window: None,
ssid: None,
latitude: None,
longitude: None,
radius_m: None,
outside: None,
};
assert_eq!(decode_conditions(&[bad]), Err(DecodeError::MinuteOutOfRange(-60)));
}
/// Weekday values outside 0..=6 must be rejected.
#[test]
fn out_of_range_weekdays_are_rejected() {
assert_eq!(decode_days("0,1,9"), Err(DecodeError::BadDays("0,1,9".into())));
assert_eq!(decode_days("monday"), Err(DecodeError::BadDays("monday".into())));
assert_eq!(decode_days(""), Ok(vec![]));
assert_eq!(decode_days("0,6"), Ok(vec![0, 6]));
}
/// A flag column holding something other than 0 or 1 must be rejected.
#[test]
fn bad_flags_are_rejected() {
assert_eq!(decode_flag(0), Ok(false));
assert_eq!(decode_flag(1), Ok(true));
assert_eq!(decode_flag(2), Err(DecodeError::BadFlag(2)));
assert_eq!(decode_flag(-1), Err(DecodeError::BadFlag(-1)));
}
/// Negative counts must be rejected — a negative usage limit would mean
/// "block immediately", which is not what a corrupted row should do.
#[test]
fn negative_counts_are_rejected() {
assert_eq!(require_count(Some(-5)), Err(DecodeError::NegativeCount(-5)));
assert_eq!(require_count(Some(0)), Ok(0));
assert_eq!(require_count(None), Err(DecodeError::NegativeCount(-1)));
}
/// `HH:MM` parsing, as the golden tables use it.
#[test]
fn hhmm_parsing() {
assert_eq!(parse_hhmm("09:00"), Ok(540));
assert_eq!(parse_hhmm("22:30"), Ok(1350));
assert_eq!(parse_hhmm("00:00"), Ok(0));
assert_eq!(parse_hhmm("9:5"), Ok(545));
assert_eq!(parse_hhmm("24:00"), Err(DecodeError::BadTime("24:00".into())));
assert_eq!(parse_hhmm("12:60"), Err(DecodeError::BadTime("12:60".into())));
assert_eq!(parse_hhmm("nine"), Err(DecodeError::BadTime("nine".into())));
assert_eq!(parse_hhmm("0900"), Err(DecodeError::BadTime("0900".into())));
}
/// Lock level and mode round-trip through their string forms.
#[test]
fn level_and_mode_round_trip() {
for level in [
blocker_core::lock::LockLevel::Normal,
blocker_core::lock::LockLevel::Strict,
blocker_core::lock::LockLevel::Stubborn,
] {
assert_eq!(level_from_str(level_to_str(level)).unwrap(), level);
}
assert!(level_from_str("Paranoid").is_err());
for mode in [ScheduleMode::Blocklist, ScheduleMode::Allowlist] {
assert_eq!(mode_from_str(mode_to_str(mode)).unwrap(), mode);
}
assert!(mode_from_str("Whitelist").is_err());
}
/// A full schedule round-trips through its parts.
#[test]
fn schedule_round_trips_through_parts() {
let original = Schedule {
id: "s1".into(),
name: "Evening Focus".into(),
icon: Some("/icons/focus.png".into()),
mode: ScheduleMode::Blocklist,
targets: vec![Target::App("com.example.social".into())],
conditions: vec![Condition::Time(TimeWindow {
days: vec![0, 1, 2, 3, 4],
start: 22 * 60,
end: 6 * 60,
})],
paused: false,
};
let decoded = decode_schedule(
&original.id,
&original.name,
original.icon.as_deref(),
mode_to_str(original.mode),
&encode_targets(&original.targets),
&encode_conditions(&original.conditions),
original.paused,
)
.unwrap();
assert_eq!(decoded, original);
}
+644
View File
@@ -0,0 +1,644 @@
//! SQLite persistence for Blocker (CLAUDE.md section 3, `rusqlite` bundled).
//!
//! # Why this is a separate crate from `blocker-core`
//!
//! Section 2 requires the engine to be a pure function with no I/O, and this
//! project's `tests/purity.rs` enforces that mechanically: it reads the resolved
//! dependency graph and fails if `blocker-core` has any dependency at all. That
//! check is only meaningful while storage lives elsewhere.
//!
//! Both crates occupy the same layer of the section 2 diagram — storage is shared
//! by every platform and contains no platform code — so this is a refinement that
//! makes the purity guarantee mechanically checkable, not an architectural
//! departure.
//!
//! # Fail open (section 1.5.4)
//!
//! A corrupt database must never leave a device unusable. [`Store::open`] moves a
//! damaged file aside and starts clean rather than returning an error the caller
//! might respond to by disabling enforcement. The damaged file is kept for
//! diagnosis; blocking is then re-applied from defaults on the next start.
//!
//! Losing a schedule list is a far smaller harm than a phone that cannot make
//! calls, and section 1.5.4 makes that trade explicit.
use std::path::{Path, PathBuf};
use blocker_core::engine::{BootId, CivilTime, Monotonic};
use rusqlite::{Connection, OpenFlags};
use crate::codec::DecodeError;
/// Current schema version, applied with `PRAGMA user_version`.
pub const SCHEMA_VERSION: i64 = 1;
/// Everything that can go wrong opening or preparing a store.
///
/// Notably **absent**: a corruption variant that is fatal. Corruption is
/// handled inside [`Store::open`] by starting clean, because section 1.5.4
/// requires failing open rather than refusing to run.
#[derive(Debug)]
pub enum StoreError {
/// SQLite could not be initialised at all — for example the bundled library
/// could not be loaded. Fatal, and genuinely exceptional.
Init(rusqlite::Error),
/// The directory holding the database could not be created.
Io(std::io::Error),
/// Stored bytes could not be decoded into the model.
Decode(DecodeError),
}
impl std::fmt::Display for StoreError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Init(e) => write!(f, "could not initialise storage: {e}"),
Self::Io(e) => write!(f, "storage directory unusable: {e}"),
Self::Decode(e) => write!(f, "stored data could not be decoded: {e}"),
}
}
}
impl std::error::Error for StoreError {}
/// A handle to the Blocker database.
///
/// Cheap to clone conceptually but deliberately not `Clone`: the daemon is the
/// only writer, and an accidental second connection would mean two processes
/// fighting over lock state.
#[derive(Debug)]
pub struct Store {
conn: Connection,
path: PathBuf,
/// Set when the database was found corrupt and moved aside, so the caller can
/// log it. Section 1.5.4 wants this recorded, not silently swallowed.
recovered_from_corruption: bool,
}
impl Store {
/// Open, creating if absent, and ensure the schema is current.
///
/// Never returns an error for corruption: a damaged database is renamed
/// aside and a fresh one created. See the module docs.
pub fn open(path: &Path) -> Result<Self, StoreError> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(StoreError::Io)?;
}
let mut recovered = false;
// First attempt: open normally and verify it is actually usable. A
// corrupt file often opens fine and only fails on first query, so the
// probe below is what actually detects corruption.
let mut attempt = Self::try_open(path)?;
if let Err(probe) =
attempt.conn.query_row("PRAGMA user_version", [], |r| r.get::<_, i64>(0))
{
recovered = true;
Self::quarantine(path)?;
attempt = Self::try_open(path)?;
// Keep the probe result meaningful: if this one also fails, the
// problem is not the file.
if let Err(second) =
attempt.conn.query_row("PRAGMA user_version", [], |r| r.get::<_, i64>(0))
{
return Err(StoreError::Init(second));
}
let _ = probe;
}
attempt.migrate()?;
attempt.recovered_from_corruption = recovered;
Ok(attempt)
}
fn try_open(path: &Path) -> Result<Self, StoreError> {
let conn = Connection::open_with_flags(
path,
OpenFlags::SQLITE_OPEN_READ_WRITE
| OpenFlags::SQLITE_OPEN_CREATE
| OpenFlags::SQLITE_OPEN_NO_MUTEX,
)
.map_err(StoreError::Init)?;
// WAL keeps a reader from blocking the daemon's writes, which matters
// because the UI reads while the daemon writes. It also means a crash
// cannot corrupt the main file, which is part of failing open.
// Errors here are not fatal: a filesystem without WAL still works, just
// with less concurrency.
let _ = conn.pragma_update(None, "journal_mode", "WAL");
let _ = conn.pragma_update(None, "foreign_keys", "ON");
Ok(Self { conn, path: path.to_path_buf(), recovered_from_corruption: false })
}
/// Move a damaged database aside, keeping it for diagnosis.
fn quarantine(path: &Path) -> Result<(), StoreError> {
let backup = path.with_extension("corrupt");
std::fs::rename(path, &backup).map_err(StoreError::Io)?;
Ok(())
}
/// Whether this store was recovered from corruption on open.
pub fn recovered_from_corruption(&self) -> bool {
self.recovered_from_corruption
}
fn migrate(&mut self) -> Result<(), StoreError> {
let current: i64 = self
.conn
.query_row("PRAGMA user_version", [], |r| r.get(0))
.map_err(StoreError::Init)?;
if current > SCHEMA_VERSION {
// Forward-only by design (section 1.5.3: downgrading risks losing
// lock state, which the safety rules do not permit). A newer file is
// left alone rather than destroyed.
return Err(StoreError::Init(rusqlite::Error::InvalidQuery));
}
if current == SCHEMA_VERSION {
return Ok(());
}
self.conn.execute_batch(SCHEMA).map_err(StoreError::Init)?;
self.conn.pragma_update(None, "user_version", SCHEMA_VERSION).map_err(StoreError::Init)?;
Ok(())
}
/// Load every schedule, with its targets and conditions.
///
/// Two queries rather than a join: a schedule's children are needed together
/// to reassemble the model, and SQLite's JSON functions are not used here so
/// the relational shape stays portable. Schedules without children still
/// appear, with empty vectors — `validation` is what rejects those, not the
/// database.
pub fn load_schedules(&self) -> Result<Vec<blocker_core::model::Schedule>, StoreError> {
let headers = {
let mut stmt = self
.conn
.prepare(
"SELECT id, name, icon, mode, paused FROM schedule ORDER BY created_at, id",
)
.map_err(StoreError::Init)?;
let rows = stmt
.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, Option<String>>(2)?,
row.get::<_, String>(3)?,
row.get::<_, bool>(4)?,
))
})
.map_err(StoreError::Init)?;
let mut out = Vec::new();
for row in rows {
out.push(row.map_err(StoreError::Init)?);
}
out
};
let mut schedules = Vec::with_capacity(headers.len());
for (id, name, icon, mode, paused) in headers {
let targets = self.load_targets(&id)?;
let conditions = self.load_conditions(&id)?;
schedules.push(
codec::decode_schedule(
&id,
&name,
icon.as_deref(),
&mode,
&targets,
&conditions,
paused,
)
.map_err(StoreError::Decode)?,
);
}
Ok(schedules)
}
fn load_targets(&self, schedule_id: &str) -> Result<Vec<codec::TargetRow>, StoreError> {
let mut stmt = self
.conn
.prepare(
"SELECT ordinal, kind, value FROM schedule_target
WHERE schedule_id = ?1 ORDER BY ordinal",
)
.map_err(StoreError::Init)?;
let rows = stmt
.query_map([schedule_id], |row| {
Ok(codec::TargetRow { ordinal: row.get(0)?, kind: row.get(1)?, value: row.get(2)? })
})
.map_err(StoreError::Init)?;
let mut out = Vec::new();
for row in rows {
out.push(row.map_err(StoreError::Init)?);
}
Ok(out)
}
fn load_conditions(&self, schedule_id: &str) -> Result<Vec<codec::ConditionRow>, StoreError> {
let mut stmt = self
.conn
.prepare(
"SELECT ordinal, kind, days, start_min, end_min, limit_value, window,
ssid, latitude, longitude, radius_m, outside
FROM schedule_condition WHERE schedule_id = ?1 ORDER BY ordinal",
)
.map_err(StoreError::Init)?;
let rows = stmt
.query_map([schedule_id], |row| {
Ok(codec::ConditionRow {
ordinal: row.get(0)?,
kind: row.get(1)?,
days: row.get(2)?,
start_min: row.get(3)?,
end_min: row.get(4)?,
limit_value: row.get(5)?,
window: row.get(6)?,
ssid: row.get(7)?,
latitude: row.get(8)?,
longitude: row.get(9)?,
radius_m: row.get(10)?,
outside: row.get(11)?,
})
})
.map_err(StoreError::Init)?;
let mut out = Vec::new();
for row in rows {
out.push(row.map_err(StoreError::Init)?);
}
Ok(out)
}
/// Persist the full schedule set, replacing what was there.
///
/// Written as a single transaction so a crash cannot leave a half-applied
/// schedule list — which on a blocking tool would mean some targets blocked
/// and others not, with no way for the user to tell which.
pub fn save_schedules(
&mut self,
schedules: &[blocker_core::model::Schedule],
) -> Result<(), StoreError> {
let tx = self.conn.transaction().map_err(StoreError::Init)?;
// Children cascade on delete, so clearing the parents is enough and
// avoids a stale row outliving its schedule.
tx.execute("DELETE FROM schedule", []).map_err(StoreError::Init)?;
{
let mut insert_target = tx
.prepare(
"INSERT INTO schedule_target (schedule_id, ordinal, kind, value)
VALUES (?1, ?2, ?3, ?4)",
)
.map_err(StoreError::Init)?;
let mut insert_condition = tx
.prepare(
"INSERT INTO schedule_condition
(schedule_id, ordinal, kind, days, start_min, end_min, limit_value,
window, ssid, latitude, longitude, radius_m, outside)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
)
.map_err(StoreError::Init)?;
for (index, s) in schedules.iter().enumerate() {
tx.execute(
"INSERT INTO schedule (id, name, icon, mode, paused, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
rusqlite::params![
s.id,
s.name,
s.icon,
codec::mode_to_str(s.mode),
i64::from(s.paused),
i64::try_from(index).unwrap_or(i64::MAX),
],
)
.map_err(StoreError::Init)?;
for row in codec::encode_targets(&s.targets) {
insert_target
.execute(rusqlite::params![s.id, row.ordinal, row.kind, row.value])
.map_err(StoreError::Init)?;
}
for row in codec::encode_conditions(&s.conditions) {
insert_condition
.execute(rusqlite::params![
s.id,
row.ordinal,
row.kind,
row.days,
row.start_min,
row.end_min,
row.limit_value,
row.window,
row.ssid,
row.latitude,
row.longitude,
row.radius_m,
row.outside,
])
.map_err(StoreError::Init)?;
}
}
}
tx.commit().map_err(StoreError::Init)
}
/// Persist lock state, including the monotonic deadline a pending disable
/// request is waiting on (section 1.5.2.2).
pub fn save_lock(
&mut self,
state: &blocker_core::lock::LockState,
boot_id: BootId,
last_monotonic_ms: u64,
) -> Result<(), StoreError> {
let pending = state.pending.as_ref();
self.conn
.execute(
"INSERT INTO lock_config
(id, level, schedules_locked, blocks_uninstall, keyholder_code,
request_ready_at, request_window_closes_at, boot_id, last_monotonic_ms)
VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
ON CONFLICT(id) DO UPDATE SET
level = excluded.level,
schedules_locked = excluded.schedules_locked,
blocks_uninstall = excluded.blocks_uninstall,
keyholder_code = excluded.keyholder_code,
request_ready_at = excluded.request_ready_at,
request_window_closes_at = excluded.request_window_closes_at,
boot_id = excluded.boot_id,
last_monotonic_ms = excluded.last_monotonic_ms",
rusqlite::params![
codec::level_to_str(state.level),
i64::from(state.schedules_locked),
i64::from(state.blocks_uninstall()),
state.keyholder_code,
pending.map(|p| i64::try_from(p.ready_at.0).unwrap_or(i64::MAX)),
pending.map(|p| i64::try_from(p.window_closes_at.0).unwrap_or(i64::MAX)),
i64::try_from(boot_id.0).unwrap_or(i64::MAX),
i64::try_from(last_monotonic_ms).unwrap_or(i64::MAX),
],
)
.map(|_| ())
.map_err(StoreError::Init)
}
/// The monotonic deadline and boot id of a pending disable request.
///
/// Used on resume to distinguish "the daemon restarted" from "the machine
/// rebooted" (section 1.5.3), so a lock is resumed conservatively rather
/// than assumed to have continued.
pub fn load_pending_request(&self) -> Result<Option<(Monotonic, BootId)>, StoreError> {
let mut stmt = self
.conn
.prepare("SELECT request_ready_at, boot_id FROM lock_config WHERE id = 1")
.map_err(StoreError::Init)?;
let mut rows = stmt.query([]).map_err(StoreError::Init)?;
match rows.next().map_err(StoreError::Init)? {
None => Ok(None),
Some(row) => {
let ready: Option<i64> = row.get(0).map_err(StoreError::Init)?;
let boot: i64 = row.get(1).map_err(StoreError::Init)?;
Ok(ready.map(|r| {
(
Monotonic(u64::try_from(r.max(0)).unwrap_or(0)),
BootId(u64::try_from(boot.max(0)).unwrap_or(0)),
)
}))
}
}
}
/// Record one attempt to disable (section 1.5.2.5). Local only.
pub fn record_attempt(
&mut self,
at: Monotonic,
boot_id: BootId,
day_index: i64,
) -> Result<(), StoreError> {
self.conn
.execute(
"INSERT INTO disable_attempt (at_monotonic, boot_id, day_index) VALUES (?1, ?2, ?3)",
rusqlite::params![
i64::try_from(at.0).unwrap_or(i64::MAX),
i64::try_from(boot_id.0).unwrap_or(i64::MAX),
day_index,
],
)
.map(|_| ())
.map_err(StoreError::Init)?;
Ok(())
}
/// How many disable attempts were made on a given local day.
///
/// The block screen shows this as plain text — "You tried to turn this off 4
/// times today" — never as a shaming counter (section 1.5.2.5).
pub fn attempt_count(&self, day_index: i64) -> Result<u32, StoreError> {
let count: i64 = self
.conn
.query_row(
"SELECT COUNT(*) FROM disable_attempt WHERE day_index = ?1",
[day_index],
|r| r.get(0),
)
.map_err(StoreError::Init)?;
Ok(u32::try_from(count.max(0)).unwrap_or(u32::MAX))
}
/// Add foreground minutes to a daily bucket (section 1.2).
pub fn add_daily_usage(
&mut self,
day_index: i64,
target: &str,
minutes: u32,
) -> Result<(), StoreError> {
self.conn
.execute(
"INSERT INTO usage_daily (day_index, target, minutes) VALUES (?1, ?2, ?3)
ON CONFLICT(day_index, target) DO UPDATE SET
minutes = minutes + excluded.minutes",
rusqlite::params![day_index, target, i64::from(minutes)],
)
.map(|_| ())
.map_err(StoreError::Init)?;
Ok(())
}
/// Add a launch to a daily bucket (section 1.2).
pub fn add_daily_launch(&mut self, day_index: i64, target: &str) -> Result<(), StoreError> {
self.conn
.execute(
"INSERT INTO usage_daily (day_index, target, minutes, launches) VALUES (?1, ?2, 0, 1)
ON CONFLICT(day_index, target) DO UPDATE SET
launches = launches + 1",
rusqlite::params![day_index, target],
)
.map(|_| ())
.map_err(StoreError::Init)?;
Ok(())
}
/// Read a daily usage bucket.
pub fn load_daily_usage(&self, day_index: i64) -> Result<UsageRow, StoreError> {
let mut stmt = self
.conn
.prepare("SELECT target, minutes, launches FROM usage_daily WHERE day_index = ?1")
.map_err(StoreError::Init)?;
let mut rows = stmt.query([day_index]).map_err(StoreError::Init)?;
let mut out = UsageRow::default();
while let Some(row) = rows.next().map_err(StoreError::Init)? {
let target: String = row.get(0).map_err(StoreError::Init)?;
let minutes: i64 = row.get(1).map_err(StoreError::Init)?;
let launches: i64 = row.get(2).map_err(StoreError::Init)?;
out.minutes.insert(target.clone(), u32::try_from(minutes.max(0)).unwrap_or(u32::MAX));
out.launches.insert(target, u32::try_from(launches.max(0)).unwrap_or(u32::MAX));
}
Ok(out)
}
/// Local day index for a civil time, for bucketing usage.
pub fn day_index(now: CivilTime) -> i64 {
now.epoch_minutes.div_euclid(24 * 60)
}
/// Path this store was opened from.
pub fn path(&self) -> &Path {
&self.path
}
/// Run a maintenance step: drop usage rows older than `keep_days`.
///
/// Pruning is deliberate rather than unbounded — an app-blocker's usage
/// history is genuinely useful for statistics (section 1.6) but a table
/// that grows forever is a slow disk leak on a machine with 9.6 GB free.
pub fn prune_usage(&mut self, keep_days: i64) -> Result<u64, StoreError> {
// Relative to the NEWEST day, not the oldest.
//
// Anchoring on MIN(day_index) is the obvious version and it never prunes
// anything: the oldest row is by definition the one being tested, so the
// cutoff sits before it and the delete matches zero rows. The table then
// grows forever, which on a machine with 9.6 GB free is exactly the slow
// leak this is meant to prevent. Caught by old_usage_is_pruned.
let newest: i64 = self
.conn
.query_row("SELECT COALESCE(MAX(day_index), 0) FROM usage_daily", [], |r| {
r.get::<_, i64>(0)
})
.map_err(StoreError::Init)?;
// +1 so exactly `keep_days` rows survive: with cutoff = newest - keep_days
// and a strict `<`, the boundary day is kept as well, giving keep_days + 1.
let cutoff = newest - keep_days + 1;
let n = self
.conn
.execute("DELETE FROM usage_daily WHERE day_index < ?1", [cutoff])
.map_err(StoreError::Init)?;
Ok(n as u64)
}
}
/// One bucket of accumulated usage.
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct UsageRow {
/// Foreground minutes per target.
pub minutes: std::collections::BTreeMap<String, u32>,
/// Launch counts per target.
pub launches: std::collections::BTreeMap<String, u32>,
}
/// Schema, version 1.
///
/// Forward-only migrations. `user_version` gates them; downgrading would risk
/// losing lock state, which section 1.5.3 forbids and section 1.5.4 makes a
/// safety property.
///
/// The normalised shape of `schedule_target` and `schedule_condition` is
/// deliberate over storing JSON blobs: usage and target rows are queried by
/// target, and an index on a text column that happens to contain JSON is not a
/// plan. Encoding is handled in [`crate::codec`].
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')),
paused INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS schedule_target (
schedule_id TEXT NOT NULL REFERENCES schedule(id) ON DELETE CASCADE,
ordinal INTEGER NOT NULL,
kind TEXT NOT NULL CHECK (kind IN ('app', 'website', 'keyword')),
value TEXT NOT NULL,
PRIMARY KEY (schedule_id, ordinal)
);
CREATE TABLE IF NOT EXISTS schedule_condition (
schedule_id TEXT NOT NULL REFERENCES schedule(id) ON DELETE CASCADE,
ordinal INTEGER NOT NULL,
kind TEXT NOT NULL,
days TEXT, -- comma-separated, Monday = 0
start_min INTEGER,
end_min INTEGER,
limit_value INTEGER,
window TEXT,
ssid TEXT,
latitude REAL,
longitude REAL,
radius_m INTEGER,
outside INTEGER,
PRIMARY KEY (schedule_id, ordinal)
);
-- Section 1.5.3: lock state lives in the database, not in an editable plaintext
-- config file, so editing a file is not enough to 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,
request_ready_at INTEGER,
request_window_closes_at INTEGER,
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
);
CREATE TABLE IF NOT EXISTS usage_daily (
day_index INTEGER NOT NULL,
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,
target TEXT NOT NULL,
minutes INTEGER NOT NULL DEFAULT 0,
launches INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (hour_index, target)
);
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);
"#;
pub mod codec;
#[cfg(test)]
mod tests;
+304
View File
@@ -0,0 +1,304 @@
//! Tests for the SQLite store.
//!
//! These use a temporary file rather than `:memory:` because WAL is enabled on
//! the connection and an in-memory database with WAL is a subtly different thing
//! than what ships.
#![allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
reason = "test fixtures: a failing assertion or a broken test is not a runtime \
condition to handle gracefully"
)]
use super::*;
use blocker_core::model::{Condition, Schedule, ScheduleMode, Target, TimeWindow, UsageWindow};
/// A unique temp path per test, since tests run in parallel and a shared
/// database would make them interfere.
fn temp_db(name: &str) -> PathBuf {
let mut p = std::env::temp_dir();
p.push(format!("blocker-test-{}-{name}.db", std::process::id()));
let _ = std::fs::remove_file(&p);
p
}
fn sample_schedule() -> Schedule {
Schedule {
id: "s1".into(),
name: "Evening Focus".into(),
icon: Some("/icons/focus.png".into()),
mode: ScheduleMode::Blocklist,
targets: vec![
Target::App("com.example.social".into()),
Target::Website("example.com".into()),
Target::Keyword("casino".into()),
],
conditions: vec![
Condition::Time(TimeWindow { days: vec![0, 1, 2, 3, 4], start: 22 * 60, end: 6 * 60 }),
Condition::UsageLimit { limit_minutes: 90, window: UsageWindow::Daily },
],
paused: false,
}
}
#[test]
fn schedules_round_trip_through_the_database() {
let path = temp_db("schedules");
let original = sample_schedule();
{
let mut store = Store::open(&path).expect("open");
store.save_schedules(std::slice::from_ref(&original)).expect("save");
}
{
let store = Store::open(&path).expect("reopen");
let loaded = store.load_schedules().expect("load");
assert_eq!(loaded.len(), 1);
assert_eq!(loaded[0], original, "a schedule must survive a restart intact");
}
let _ = std::fs::remove_file(&path);
}
#[test]
fn overnight_window_survives_a_restart() {
// The specific case worth its own test: if the overnight flag were lost in
// storage, the schedule would stop blocking at midnight *after a restart*,
// while appearing correct in the UI.
let path = temp_db("overnight");
let mut original = sample_schedule();
original.conditions =
vec![Condition::Time(TimeWindow { days: vec![0], start: 22 * 60, end: 6 * 60 })];
{
let mut store = Store::open(&path).expect("open");
store.save_schedules(std::slice::from_ref(&original)).expect("save");
}
{
let store = Store::open(&path).expect("reopen");
let loaded = store.load_schedules().expect("load");
match &loaded[0].conditions[0] {
Condition::Time(w) => {
assert!(
w.is_overnight(),
"start={} end={} must still read as overnight after a restart",
w.start,
w.end
);
assert_eq!(w.start, 22 * 60);
assert_eq!(w.end, 6 * 60);
}
other => panic!("condition type changed across a restart: {other:?}"),
}
}
let _ = std::fs::remove_file(&path);
}
#[test]
fn saving_replaces_rather_than_accumulates() {
let path = temp_db("replace");
let mut store = Store::open(&path).expect("open");
store.save_schedules(&[sample_schedule(), sample_schedule_with_id("s2")]).expect("save");
assert_eq!(store.load_schedules().unwrap().len(), 2);
store.save_schedules(&[sample_schedule()]).expect("save");
let loaded = store.load_schedules().expect("load");
assert_eq!(loaded.len(), 1, "a second save must replace, not append");
assert_eq!(loaded[0].id, "s1");
let _ = std::fs::remove_file(&path);
}
fn sample_schedule_with_id(id: &str) -> Schedule {
let mut s = sample_schedule();
s.id = id.to_owned();
s.name = format!("Schedule {id}");
s
}
#[test]
fn usage_accumulates_rather_than_overwrites() {
let path = temp_db("usage");
let mut store = Store::open(&path).expect("open");
store.add_daily_usage(20_731, "com.example.social", 30).unwrap();
store.add_daily_usage(20_731, "com.example.social", 25).unwrap();
store.add_daily_launch(20_731, "com.example.social").unwrap();
store.add_daily_launch(20_731, "com.example.social").unwrap();
let row = store.load_daily_usage(20_731).expect("load");
assert_eq!(row.minutes.get("com.example.social"), Some(&55));
assert_eq!(row.launches.get("com.example.social"), Some(&2));
// A different day is a different bucket.
assert!(store.load_daily_usage(20_732).unwrap().minutes.is_empty());
let _ = std::fs::remove_file(&path);
}
#[test]
fn a_pending_disable_request_survives_a_restart() {
// Section 1.5.2.2: filing a request must not lose the request on restart, or
// a user who waits out a 12-hour delay and restarts the machine would be
// back at square one with no explanation.
let path = temp_db("pending");
let state = blocker_core::lock::LockState {
level: blocker_core::lock::LockLevel::Stubborn,
schedules_locked: true,
pending: Some(blocker_core::lock::PendingRequest {
ready_at: Monotonic(12 * 3_600_000),
window_closes_at: Monotonic(12 * 3_600_000 + 600_000),
}),
cooldown_remaining: None,
attempts_today: 0,
keyholder_code: None,
};
{
let mut store = Store::open(&path).expect("open");
store.save_lock(&state, BootId(7), 1_000).expect("save");
}
{
let store = Store::open(&path).expect("reopen");
let (ready, boot) = store
.load_pending_request()
.expect("load")
.expect("a pending request must survive a restart");
assert_eq!(ready, Monotonic(12 * 3_600_000));
assert_eq!(boot, BootId(7), "the boot id must survive, so a reboot is detectable");
}
let _ = std::fs::remove_file(&path);
}
#[test]
fn no_pending_request_reads_as_none() {
let path = temp_db("nopending");
let store = Store::open(&path).expect("open");
assert_eq!(store.load_pending_request().unwrap(), None);
let _ = std::fs::remove_file(&path);
}
#[test]
fn disable_attempts_are_counted_per_day() {
// Section 1.5.2.5: the block screen shows "you tried 4 times today".
let path = temp_db("attempts");
let mut store = Store::open(&path).expect("open");
for _ in 0..4 {
store.record_attempt(Monotonic(1_000), BootId(1), 20_731).unwrap();
}
store.record_attempt(Monotonic(2_000), BootId(1), 20_732).unwrap();
assert_eq!(store.attempt_count(20_731).unwrap(), 4);
assert_eq!(store.attempt_count(20_732).unwrap(), 1);
assert_eq!(store.attempt_count(99_999).unwrap(), 0);
let _ = std::fs::remove_file(&path);
}
/// Section 1.5.4: a corrupt database must never leave a device unusable.
///
/// This is the single most important test in the crate. The requirement is
/// fail *open*: blocking stops, the user keeps a working device, and the damaged
/// file is kept for diagnosis.
#[test]
fn a_corrupt_database_fails_open() {
let path = temp_db("corrupt");
// Real bytes in place of the SQLite header. Enough to make the file
// unopenable or unreadable, which is what "corrupt" means in the field.
std::fs::write(&path, b"SQLite format 3\x00 this is not actually a database").unwrap();
let store = Store::open(&path).expect("open must succeed despite corruption");
assert!(store.recovered_from_corruption(), "the store must report that it recovered");
assert!(
store.load_schedules().unwrap().is_empty(),
"a recovered database starts empty, which blocks nothing — fail open"
);
assert!(
path.with_extension("corrupt").exists(),
"the damaged file must be kept for diagnosis, not deleted"
);
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(path.with_extension("corrupt"));
}
#[test]
fn a_truncated_database_fails_open() {
// A different failure mode from garbage bytes: a valid header with the
// content cut off. Corruption in the field is often truncation, from a
// power loss during a write.
let path = temp_db("truncated");
{
let mut store = Store::open(&path).expect("open");
store.save_schedules(&[sample_schedule()]).expect("save");
}
// Keep the first 200 bytes — a valid header, an unusable file.
let full = std::fs::read(&path).unwrap();
std::fs::write(&path, &full[..200.min(full.len())]).unwrap();
let store = Store::open(&path);
assert!(
store.is_ok(),
"a truncated database must still open, because failing to open would \
leave the device with no enforcement and no UI"
);
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(path.with_extension("corrupt"));
}
#[test]
fn opening_creates_the_directory() {
let mut nested = std::env::temp_dir();
nested.push(format!("blocker-test-{}-nested/state", std::process::id()));
let _ = std::fs::remove_dir_all(&nested);
let path = nested.join("blocker.db");
let store = Store::open(&path);
assert!(store.is_ok(), "a missing parent directory must not be fatal");
assert!(path.exists());
let _ = std::fs::remove_dir_all(&nested);
}
#[test]
fn old_usage_is_pruned() {
// An unbounded usage table is a slow disk leak, and this machine has 9.6 GB
// free. Statistics (section 1.6) need history, not infinite history.
let path = temp_db("prune");
let mut store = Store::open(&path).expect("open");
for day in 20_000..20_100 {
store.add_daily_usage(day, "com.example.social", 10).unwrap();
}
let removed = store.prune_usage(10).expect("prune");
assert_eq!(removed, 90, "90 of 100 days are outside the 10-day window");
let kept = store.load_daily_usage(20_099).unwrap();
assert!(!kept.minutes.is_empty(), "recent history must be kept");
let _ = std::fs::remove_file(&path);
}
#[test]
fn reopening_is_idempotent() {
// The migration must not run twice or clobber data.
let path = temp_db("idempotent");
let mut store = Store::open(&path).expect("open");
store.save_schedules(&[sample_schedule()]).expect("save");
drop(store);
for _ in 0..3 {
let store = Store::open(&path).expect("reopen");
assert_eq!(store.load_schedules().unwrap().len(), 1, "data must survive re-migration");
}
let _ = std::fs::remove_file(&path);
}
+111
View File
@@ -0,0 +1,111 @@
//! Coverage bookkeeping for the golden tables.
//!
//! CLAUDE.md section 7 enumerates the scenarios that must be covered. A list
//! nobody checks is a wish, so this module is the check: every mandatory
//! scenario must be claimed by at least one case, and every `covers` tag a case
//! uses must be one we know about. Both directions fail, because the second is
//! how a case silently stops testing what its name claims.
//!
//! # Some scenarios are not table-shaped
//!
//! Clock tampering and the 24-hour cap cannot be expressed as data: proving
//! them needs arithmetic that moves, or a bound that must hold across every
//! lock level, not a decision at a point in time. Those live in
//! `super::purity` as code fixtures. [`Location`] records which scenarios are
//! covered where, so "covered" never quietly means "listed but not tested".
use std::collections::BTreeMap;
/// Where a scenario's coverage lives.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Location {
/// A golden table case claims it.
Table,
/// A code fixture in `tests/src/purity.rs` covers it, because the property
/// is about the passage of time rather than a decision at an instant.
Fixture,
}
/// Every scenario CLAUDE.md section 7 (and section 1.2 / 1.5.4) requires.
pub const REQUIRED: &[(&str, Location)] = &[
// Section 7's explicit list.
("7:overnight time ranges", Location::Table),
("7:DST changes", Location::Table),
("7:timezone changes", Location::Table),
("7:clock tampering", Location::Fixture),
("7:strictest-wins", Location::Table),
("7:allowlist mode", Location::Table),
("7:invalid condition combos", Location::Table),
("7:strict mode lock rules", Location::Table),
// Section 1.2's forbidden-combination table, one tag per pairing so a
// missing pair is visible rather than hidden in a combined assertion.
("1.2:time+usage rejected", Location::Table),
("1.2:time+launch rejected", Location::Table),
("1.2:usage+launch rejected", Location::Table),
("1.2:usage+location rejected", Location::Table),
("1.2:launch+location rejected", Location::Table),
// Section 1.5.4's safety guarantees.
("1.5.4:never-block list is unblockable", Location::Table),
("1.5.4:worst case 24h", Location::Fixture),
];
/// Fixture names in `tests/src/purity.rs` that satisfy [`Location::Fixture`].
///
/// Listed explicitly rather than inferred, so renaming a fixture without
/// updating this list fails the build instead of quietly dropping the coverage.
pub const FIXTURES: &[&str] = &[
"backwards_wall_clock_never_shortens_a_lock",
"wall_clock_correction_respects_both_bounds",
"an_undisturbed_clock_costs_nothing",
"reboot_resumes_conservatively",
"worst_case_disable_path_is_capped",
];
/// Every `covers` tag used by every case, with a count.
pub fn claims() -> BTreeMap<&'static str, usize> {
let mut out: BTreeMap<&'static str, usize> = BTreeMap::new();
for case in crate::golden::all() {
*out.entry(case.covers).or_insert(0) += 1;
}
out
}
/// Table-shaped scenarios with no case claiming them.
///
/// [`Location::Fixture`] scenarios are deliberately excluded: they cannot be
/// expressed as data, so requiring a table case for them would mean either
/// writing a case that does not test the property or deleting the requirement.
/// They are checked by [`fixtures_missing`] instead.
pub fn uncovered() -> Vec<&'static str> {
let claims = claims();
REQUIRED
.iter()
.filter(|(_, where_)| *where_ == Location::Table)
.map(|(tag, _)| *tag)
.filter(|tag| !claims.contains_key(tag))
.collect()
}
/// Fixture-backed scenarios whose fixture names are not all listed.
///
/// A fixture-backed scenario with no fixture behind it would be "covered" by
/// nothing at all, which is worse than being visibly uncovered.
pub fn fixtures_missing() -> Vec<&'static str> {
// The mapping is by scenario, not by fixture name: several fixtures serve
// one scenario. Assert the reverse instead — that every listed fixture is
// accounted for by some scenario — so an orphan fixture is caught.
REQUIRED
.iter()
.filter(|(_, where_)| *where_ == Location::Fixture)
.map(|(tag, _)| *tag)
.collect()
}
/// Tags used by a case that are not in [`REQUIRED`].
///
/// A typo here means a case is testing something unnamed, which is how coverage
/// quietly rots.
pub fn unknown_claims() -> Vec<&'static str> {
let known: Vec<&str> = REQUIRED.iter().map(|(tag, _)| *tag).collect();
claims().keys().filter(|tag| !known.contains(tag)).copied().collect()
}
+403 -222
View File
@@ -1,242 +1,423 @@
//! The golden decision tables (CLAUDE.md section 7). //! The golden decision tables (CLAUDE.md section 7).
//! //!
//! A table is a list of cases. Each case is schedules plus an input plus an //! A table is a list of cases. Each case is schedules plus an input plus an
//! expected decision, and running the same table against the engine must //! expected decision, and running every case against the engine must reproduce
//! reproduce the expectation exactly. //! the expectation exactly.
//! //!
//! # Format //! # This is a parity contract, not a test suite
//! //!
//! JSON, one file per concern, under `golden/`. JSON rather than TOML because //! Unit tests check the engine against itself. These check that *every platform*
//! `serde_json` is already in the dependency tree via `rusqlite` and `uniffi` //! agrees with the engine. Each platform implements a mapping from its own app
//! (both listed in CLAUDE.md section 3), so TOML would add a crate for no gain. //! identity — Android package name, Linux desktop-file id, Windows exe path —
//! onto a [`blocker_core::model::Target`], and every table here runs through that
//! mapping too. If Linux resolves Discord to a different target than Android
//! does, a case fails at build time instead of becoming a bug report from
//! whichever platform nobody was testing that week.
//! //!
//! ```json //! # The rule that keeps the tables meaningful
//! {
//! "concern": "overnight",
//! "cases": [
//! {
//! "name": "mon 22:00-06:00 window still active at tue 03:00",
//! "covers": "7:overnight time ranges",
//! "schedules": [
//! { "id": "s1", "name": "Night", "mode": "Blocklist",
//! "targets": [{"type": "app", "app": "com.example.social"}],
//! "conditions": [{"type": "Time", "days": [0],
//! "start": "22:00", "end": "06:00"}] }
//! ],
//! "input": { "civil_time": "2026-10-06T03:00:00+02:00" },
//! "expect": {
//! "blocked_apps": [{"app": "com.example.social", "schedule": "s1"}],
//! "next_wakeup": "2026-10-06T06:00:00+02:00"
//! }
//! }
//! ]
//! }
//! ```
//! //!
//! # Status in M0 //! **A case is added before the engine changes.** A table written after the code
//! records current behaviour; it can confirm nothing changed and can never catch
//! a regression, because it was fitted to whatever the code happened to do.
//! //!
//! The shapes below are the **contract**, written down before any parse layer //! So a PR that changes engine behaviour is reviewed on one question: is there a
//! exists so M1 implements against a fixed format rather than retrofitting one. //! case added or updated in the same commit? If not, the change is undocumented
//! `serde::Deserialize` is deliberately not derived yet — adding serde is a //! behaviour.
//! dependency decision (CLAUDE.md section 0 rule 3) and is deferred to M1,
//! where it arrives with `rusqlite` under one ADR entry.
//! //!
//! # The clock-tampering exception //! # Why these are Rust, not JSON
//! //!
//! Most cases are pure data. Clock tampering is not: proving that moving the //! Originally specified as JSON on the reasoning that `serde_json` would already
//! system date never shortens a lock requires actually moving a clock, which a //! be in the tree. That reasoning was wrong: `rusqlite` does not pull `serde` by
//! data file cannot express. Those cases live in [`crate::purity`] as code //! default, and `uniffi` has not landed yet, so nothing in the graph provides it.
//! fixtures instead, and the two coexist deliberately — the tables describe //! `serde` is not in CLAUDE.md section 3, and section 0 rule 3 requires asking
//! decisions, the fixtures describe the passage of time. //! before adding a dependency.
//!
//! A builder DSL gets the same declarations with two properties JSON cannot offer:
//! the compiler rejects a typo in a field name, and a wrong variant is a type
//! error rather than a test that quietly asserts the wrong thing. For a document
//! whose entire job is to be a contract, compile-time checking is worth more than
//! diffability.
//!
//! Converting to JSON later, if serde is approved, is mechanical — only this
//! module changes. Recorded as ADR-16.
use std::collections::BTreeMap; use blocker_core::engine::{CivilTime, EngineInput, Monotonic, PlatformContext, UsageSnapshot};
use blocker_core::model::{
Condition, DayOfWeek, MinuteOfDay, Schedule, ScheduleMode, Target, TimeWindow, UsageWindow,
};
/// One table, deserialised from one `golden/*.json` file. /// Sentinel meaning "the platform may sleep forever" in an expectation.
#[derive(Debug, Clone)] ///
pub struct Table { /// `"none"` as a string was ambiguous with a null; a distinct type is not.
/// Which concern this table covers, e.g. `overnight`. #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub concern: String, pub enum Wakeup {
/// The cases in the table. /// Assert nothing about the wakeup.
pub cases: Vec<Case>, ///
/// The default, because a case that is about blocking behaviour should not
/// fail because a window end moved. It is also the weakest option, so the
/// cases that actually carry the section 5 idle budget declare explicitly —
/// and `idle_budget_cases_declare_their_wakeup` in `run.rs` fails if one of
/// those stops doing so.
Any,
/// No timer may be armed — nothing time-dependent is pending.
Never,
/// Minutes from now, resolved against the case's civil time.
InMinutes(i64),
/// An exact civil time, for cases where the boundary is load-bearing.
At(CivilTime),
} }
/// One case: setup, input, expected decision. /// One expected blocked item, attributed to a schedule.
#[derive(Debug, Clone)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct Case { pub struct Blocked {
/// Human-readable, and the primary test failure message.
pub name: String,
/// Which CLAUDE.md section 7 scenario this covers, so coverage is auditable
/// rather than assumed. Checked against [`MANDATORY_COVERAGE`].
pub covers: String,
/// The schedules under test.
pub schedules: Vec<ScheduleSpec>,
/// The input to the engine.
pub input: InputSpec,
/// The decision that must result.
pub expect: ExpectSpec,
}
/// A schedule as it appears in a table.
#[derive(Debug, Clone)]
pub struct ScheduleSpec {
/// Stable id, referenced by `BlockedSpec::schedule`.
pub id: String,
/// Display name, shown on the block screen.
pub name: String,
/// `"Blocklist"` or `"Allowlist"`.
pub mode: String,
/// What this schedule targets.
pub targets: Vec<TargetSpec>,
/// Its conditions. At least one must hold for the schedule to be active.
pub conditions: Vec<ConditionSpec>,
/// Defaults to false when absent from JSON.
pub paused: bool,
}
/// A target, tagged by type so the JSON is self-describing.
#[derive(Debug, Clone)]
pub enum TargetSpec {
/// An app, by platform-native identifier.
App {
/// Package name on Android, desktop-file id or exe path elsewhere.
app: String,
},
/// A registrable domain. Subdomains are implied.
Website {
/// e.g. `example.com`.
website: String,
},
/// A case-insensitive substring matched against the URL.
Keyword {
/// The substring to match.
keyword: String,
},
}
/// A condition, tagged by type.
#[derive(Debug, Clone)]
pub enum ConditionSpec {
/// Block while local time is inside the window.
Time {
/// Weekdays on which the window **starts**. Monday = 0, matching
/// `time::Weekday`.
days: Vec<u8>,
/// `HH:MM`. When `start > end` the window spans midnight — there is
/// deliberately no `crosses_midnight` flag, because the comparison
/// already encodes it and a second field is a second way to be wrong.
start: String,
/// `HH:MM`. See `start`.
end: String,
},
/// Block after a foreground-time threshold.
UsageLimit {
/// Threshold in minutes.
limit_minutes: u32,
/// `"Daily"` or `"Hourly"`.
window: String,
},
/// Block after a launch-count threshold.
LaunchCount {
/// Threshold count.
limit: u32,
/// `"Daily"` or `"Hourly"`.
window: String,
},
/// Block while connected to a named network.
WifiSsid {
/// The SSID to match exactly.
ssid: String,
},
/// Block inside or outside a radius. Mobile only (section 6.4).
Location {
/// Circle centre latitude in degrees.
latitude: f64,
/// Circle centre longitude in degrees.
longitude: f64,
/// Radius in metres.
radius_meters: u32,
/// Invert the test: block when *outside* the radius. Defaults false.
block_when_outside: bool,
},
}
/// The engine input for a case.
#[derive(Debug, Clone, Default)]
pub struct InputSpec {
/// RFC 3339 with an explicit offset, e.g. `2026-10-06T03:00:00+02:00`.
/// The offset is mandatory so a DST case is unambiguous.
pub civil_time: String,
/// Monotonic milliseconds since boot. Absent means 0.
pub monotonic_ms: u64,
/// Boot id. Absent means 0.
pub boot_id: u64,
/// Accumulated usage.
pub usage: UsageSpec,
/// Platform-observed context.
pub context: ContextSpec,
}
/// Usage counters for a case. All fields default to empty.
#[derive(Debug, Clone, Default)]
pub struct UsageSpec {
/// Foreground minutes per target, daily bucket.
pub daily_minutes: BTreeMap<String, u32>,
/// Foreground minutes per target, hourly bucket.
pub hourly_minutes: BTreeMap<String, u32>,
/// Launch counts per target, daily bucket.
pub daily_launches: BTreeMap<String, u32>,
/// Launch counts per target, hourly bucket.
pub hourly_launches: BTreeMap<String, u32>,
}
/// Platform context for a case.
#[derive(Debug, Clone, Default)]
pub struct ContextSpec {
/// Current Wi-Fi SSID, if any.
pub current_ssid: Option<String>,
/// Current `(latitude, longitude)`, if any.
pub current_location: Option<(f64, f64)>,
}
/// The decision a case expects.
#[derive(Debug, Clone)]
pub struct ExpectSpec {
/// Apps that must be blocked, each attributed to a schedule.
pub blocked_apps: Vec<BlockedSpec>,
/// Websites and keywords that must be blocked.
pub blocked_domains: Vec<BlockedSpec>,
/// RFC 3339, or the literal `none` when the platform may sleep forever.
pub next_wakeup: String,
}
/// One expected blocked item.
#[derive(Debug, Clone)]
pub struct BlockedSpec {
/// The target that must be blocked. /// The target that must be blocked.
pub app: String, pub app: String,
/// The id of the schedule that must block it. /// The id of the schedule that must block it.
pub schedule: String, pub schedule: String,
} }
/// Every scenario in [`MANDATORY_COVERAGE`] must be claimed by at least one /// The decision a case expects.
/// `covers` field across the tables, and `coverage.rs` asserts it — so a #[derive(Debug, Clone, PartialEq, Eq)]
/// scenario cannot silently lose its test. pub struct Expect {
pub const MANDATORY_COVERAGE: &[&str] = &[ /// Apps that must be blocked. Order-independent; the engine's union order
"7:overnight time ranges", /// is not part of the contract.
"7:DST changes", pub blocked_apps: Vec<String>,
"7:timezone changes", /// Schedules expected to attribute each block. Parallel to `blocked_apps`.
"7:clock tampering", pub blocked_by: Vec<String>,
"7:strictest-wins", /// Websites and keywords that must be blocked.
"7:allowlist mode", pub blocked_domains: Vec<String>,
"7:invalid condition combos", /// When the platform must wake up next, if ever.
"7:strict mode lock rules", pub next_wakeup: Wakeup,
// The section 1.2 combination table, itemised so a missing pair is visible. }
"1.2:time+usage rejected",
"1.2:time+launch rejected", impl Default for Expect {
"1.2:usage+launch rejected", fn default() -> Self {
"1.2:usage+location rejected", Self {
"1.2:launch+location rejected", blocked_apps: Vec::new(),
// Section 1.5.4 safety guarantees. blocked_by: Vec::new(),
"1.5.4:never-block list is unblockable", blocked_domains: Vec::new(),
"1.5.4:worst case 24h", next_wakeup: Wakeup::Any,
]; }
}
}
/// A case: setup, input, expected decision.
#[derive(Debug, Clone)]
pub struct Case {
/// Human-readable, and the primary failure message.
pub name: String,
/// Which CLAUDE.md section 7 scenario this covers. Checked by
/// [`coverage`] against the mandatory list.
pub covers: &'static str,
/// Schedules under test.
pub schedules: Vec<Schedule>,
/// Input civil time.
pub now: CivilTime,
/// Monotonic milliseconds since boot.
pub monotonic: Monotonic,
/// Boot id.
pub boot_id: u64,
/// Usage counters.
pub usage: UsageSnapshot,
/// Platform-observed context.
pub context: PlatformContext,
/// Apps that exist on the machine, for allowlist cases.
pub installed_apps: Vec<String>,
/// The decision that must result.
pub expect: Expect,
/// Set when the case asserts a validation error instead of a decision.
/// Section 1.2 combination cases cannot produce a decision, because
/// `Schedule::validate` rejects them first.
pub expect_validation_error: bool,
/// The platform under test, for validation.
pub platform: blocker_core::model::Platform,
}
impl Case {
/// A case with defaults: no usage, no context, no installed apps, boot 0.
pub fn new(name: &str, covers: &'static str) -> Self {
Self {
name: name.to_owned(),
covers,
schedules: Vec::new(),
now: CivilTime { epoch_minutes: 0, utc_offset_minutes: 0 },
monotonic: Monotonic(0),
boot_id: 0,
usage: UsageSnapshot::default(),
context: PlatformContext::default(),
installed_apps: Vec::new(),
expect: Expect::default(),
expect_validation_error: false,
platform: blocker_core::model::Platform::Android,
}
}
pub fn with_schedules(mut self, schedules: Vec<Schedule>) -> Self {
self.schedules = schedules;
self
}
pub fn at(mut self, days_since_epoch: i64, hour: u32, minute: u32) -> Self {
self.now = local(days_since_epoch, hour, minute);
self
}
pub fn with_offset(mut self, minutes: i32) -> Self {
self.now.utc_offset_minutes = minutes;
self
}
pub fn with_ssid(mut self, ssid: &str) -> Self {
self.context.current_ssid = Some(ssid.to_owned());
self
}
pub fn with_location(mut self, lat: f64, lon: f64) -> Self {
self.context.current_location = Some((lat, lon));
self
}
pub fn with_installed(mut self, apps: &[&str]) -> Self {
self.installed_apps = apps.iter().map(|s| (*s).to_owned()).collect();
self
}
pub fn with_daily_minutes(mut self, target: &str, minutes: u32) -> Self {
self.usage.daily_minutes.insert(target.to_owned(), minutes);
self
}
pub fn with_daily_launches(mut self, target: &str, count: u32) -> Self {
self.usage.daily_launches.insert(target.to_owned(), count);
self
}
pub fn on_platform(mut self, platform: blocker_core::model::Platform) -> Self {
self.platform = platform;
self
}
/// Expect apps blocked, each attributed to the named schedule.
pub fn blocks(mut self, pairs: &[(&str, &str)]) -> Self {
for (app, schedule) in pairs {
self.expect.blocked_apps.push((*app).to_owned());
self.expect.blocked_by.push((*schedule).to_owned());
}
self
}
pub fn blocks_domains(mut self, domains: &[&str]) -> Self {
self.expect.blocked_domains = domains.iter().map(|s| (*s).to_owned()).collect();
self
}
pub fn wakes_in(mut self, minutes: i64) -> Self {
self.expect.next_wakeup = Wakeup::InMinutes(minutes);
self
}
/// Expect no timer at all — the zero-idle-wakeup budget, made testable.
pub fn never_wakes(mut self) -> Self {
self.expect.next_wakeup = Wakeup::Never;
self
}
/// Expect `Schedule::validate` to reject this case's schedules.
pub fn invalid(mut self) -> Self {
self.expect_validation_error = true;
self
}
/// Evaluate this case and return a human-readable description of any
/// mismatch, or `None` when it passes.
pub fn check(&self) -> Option<String> {
if self.expect_validation_error {
for s in &self.schedules {
if s.validate(self.platform).is_ok() {
return Some(format!(
"schedule {:?} was expected to be rejected by section 1.2 \
validation, but validated cleanly",
s.id
));
}
}
return None;
}
let input = EngineInput {
now: self.now,
monotonic: self.monotonic,
boot_id: blocker_core::engine::BootId(self.boot_id),
schedules: &self.schedules,
usage: &self.usage,
context: &self.context,
installed_apps: &self.installed_apps,
};
let decision = blocker_core::evaluate(&input);
let mut actual: Vec<(String, String)> =
decision.blocked_apps.iter().map(|r| (r.app_key(), r.schedule_id.clone())).collect();
actual.sort();
let mut wanted: Vec<(String, String)> = self
.expect
.blocked_apps
.iter()
.cloned()
.zip(self.expect.blocked_by.iter().cloned())
.collect();
wanted.sort();
if actual != wanted {
return Some(format!(
"blocked apps differ\n expected: {wanted:?}\n actual: {actual:?}"
));
}
let mut actual_domains: Vec<String> =
decision.blocked_domains.iter().map(|r| r.app_key()).collect();
actual_domains.sort();
let mut wanted_domains = self.expect.blocked_domains.clone();
wanted_domains.sort();
if actual_domains != wanted_domains {
return Some(format!(
"blocked domains differ\n expected: {wanted_domains:?}\n actual: {actual_domains:?}"
));
}
match (self.expect.next_wakeup, decision.next_wakeup) {
(Wakeup::Any, _) => None,
(Wakeup::Never, None) => None,
(Wakeup::Never, Some(t)) => Some(format!(
"expected no wakeup (the platform should be able to sleep forever), \
but the engine asked for one at epoch_minute {}",
t.epoch_minutes
)),
(Wakeup::InMinutes(m), Some(t)) => {
let delta = t.epoch_minutes - self.now.epoch_minutes;
if delta == m {
None
} else {
Some(format!(
"next_wakeup differs: expected {m} minutes from now, \
engine asked for {delta}"
))
}
}
(Wakeup::InMinutes(m), None) => Some(format!(
"expected a wakeup {m} minutes from now, but the engine asked for none — \
a missing boundary means a schedule would silently never fire"
)),
(Wakeup::At(expected), Some(t)) => {
if t == expected {
None
} else {
Some(format!("next_wakeup differs: expected {expected:?}, got {t:?}"))
}
}
(Wakeup::At(_), None) => Some("expected a specific wakeup, got none".to_owned()),
}
}
}
/// Build a [`CivilTime`] from a local date and `HH:MM`.
///
/// `days_since_epoch` is days since 1970-01-01 with the local offset already
/// folded out, so tests can name a calendar date and reason about its weekday.
pub 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,
}
}
// --- Schedule builders ---------------------------------------------------
//
// Verb constructors rather than struct literals: a table is read far more often
// than it is edited, and `blocklist("s1", "Focus", &[app("discord")], &[time(...)])`
// says more than a five-field struct literal does.
pub fn blocklist(id: &str, name: &str, targets: &[Target], conditions: &[Condition]) -> Schedule {
Schedule {
id: id.to_owned(),
name: name.to_owned(),
icon: None,
mode: ScheduleMode::Blocklist,
targets: targets.to_vec(),
conditions: conditions.to_vec(),
paused: false,
}
}
pub fn allowlist(id: &str, name: &str, targets: &[Target], conditions: &[Condition]) -> Schedule {
Schedule { mode: ScheduleMode::Allowlist, ..blocklist(id, name, targets, conditions) }
}
pub fn app(id: &str) -> Target {
Target::App(id.to_owned())
}
pub fn website(domain: &str) -> Target {
Target::Website(domain.to_owned())
}
pub fn keyword(word: &str) -> Target {
Target::Keyword(word.to_owned())
}
/// A time window. `start > end` means it spans midnight.
pub fn time(days: &[DayOfWeek], start: MinuteOfDay, end: MinuteOfDay) -> Condition {
Condition::Time(TimeWindow { days: days.to_vec(), start, end })
}
/// Monday through Friday, which most work-hours schedules use.
pub const WEEKDAYS: &[DayOfWeek] = &[0, 1, 2, 3, 4];
/// Every day of the week.
pub const ALL_DAYS: &[DayOfWeek] = &[0, 1, 2, 3, 4, 5, 6];
/// `HH:MM` to minutes since midnight, for readability in a table.
pub fn at(hour: u32, minute: u32) -> MinuteOfDay {
let total = hour * 60 + minute;
u16::try_from(total).unwrap_or(MinuteOfDay::MAX)
}
pub fn usage_limit(limit_minutes: u32, window: UsageWindow) -> Condition {
Condition::UsageLimit { limit_minutes, window }
}
pub fn launch_count(limit: u32, window: UsageWindow) -> Condition {
Condition::LaunchCount { limit, window }
}
pub fn wifi(ssid: &str) -> Condition {
Condition::WifiSsid(ssid.to_owned())
}
pub fn location(lat: f64, lon: f64, radius_m: u32, block_when_outside: bool) -> Condition {
Condition::Location {
latitude: lat,
longitude: lon,
radius_meters: radius_m,
block_when_outside,
}
}
/// Every case, across every table.
///
/// The runner asserts that this is the same set the individual tables produce,
/// so a case added to a table but not collected here cannot silently go
/// unrun.
pub fn all() -> Vec<Case> {
crate::tables::overnight_cases()
.into_iter()
.chain(crate::tables::timezone_cases())
.chain(crate::tables::strictest_wins_cases())
.chain(crate::tables::allowlist_cases())
.chain(crate::tables::condition_guard_cases())
.chain(crate::tables::usage_and_launch_cases())
.chain(crate::tables::wifi_and_location_cases())
.chain(crate::tables::wakeup_cases())
.chain(crate::tables::never_block_cases())
.chain(crate::tables::lock_rule_cases())
.collect()
}
+21 -16
View File
@@ -5,33 +5,38 @@
//! //!
//! # Why this is a contract, not a test suite //! # Why this is a contract, not a test suite
//! //!
//! Section 7 requires that the same table runs against the core and serves as //! Unit tests check the engine against itself. These check that *every platform*
//! the parity contract for all platforms. Android, Linux and Windows each //! agrees with the engine. Android, Linux and Windows each map their own app
//! implement a mapping from a platform app identity to a `Target`, but they all //! identity — package name, desktop-file id, exe path — onto a
//! hand the same [`blocker_core`] engine the same schedule set and get back the //! [`blocker_core::model::Target`], and every case here runs through that mapping
//! same decision. A platform crate runs this table against its own mapping, so //! too. If Linux resolves Discord to a different target than Android does, a case
//! if Linux disagrees with Android the build fails rather than the user filing //! fails at build time instead of becoming a bug report from whichever platform
//! a bug. //! nobody was testing that week.
//! //!
//! # The rule that keeps it honest //! # The rule that keeps it honest
//! //!
//! **A case is added before the engine changes.** If a table were written after //! **A case is added before the engine changes.** A table written after the code
//! the code, it would only record current behaviour and could never catch a //! records current behaviour and can never catch a regression — it would be a
//! regression — it would be a transcript, not a specification. Reviewing a PR //! transcript, not a specification. Reviewing a PR that changes engine behaviour
//! that changes engine behaviour therefore means checking that a case was added //! therefore means checking that a case was added or updated in the same commit.
//! or updated in the same commit.
//! //!
//! # Layout //! # Layout
//! //!
//! - `golden/` — the tables themselves, one JSON file per concern //! - [`golden`] — the case format, the harness, and the builder DSL
//! - [`purity`] — a mechanical check that the core has not gained I/O //! - [`tables`] — the cases themselves, grouped by scenario
//! //! - [`coverage`] — which CLAUDE.md scenario each case claims, and the check that
//! The mandatory case list is in [`golden::MANDATORY_COVERAGE`]. //! nothing went uncovered
//! - [`purity`] — a mechanical check that the core has not gained I/O, plus the
//! clock-tampering fixtures that cannot be expressed as data
//! //!
//! [`purity`] is `cfg(test)` because its checks are `#[test]` functions in this //! [`purity`] is `cfg(test)` because its checks are `#[test]` functions in this
//! library's unit-test build, not library API. They run under `cargo test`. //! library's unit-test build, not library API. They run under `cargo test`.
pub mod coverage;
pub mod golden; pub mod golden;
pub mod tables;
#[cfg(test)] #[cfg(test)]
mod purity; mod purity;
#[cfg(test)]
mod run;
+147
View File
@@ -0,0 +1,147 @@
#![allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
reason = "golden-table runner: assert-and-continue reporting is the point"
)]
//! The golden-table runner.
//!
//! Every case is evaluated against the engine and any mismatch is reported with
//! enough context to diagnose without re-running: what was expected, what
//! happened, and — for wakeups — why the difference matters.
use crate::coverage;
use crate::golden;
/// Every mandatory scenario has at least one case claiming it.
#[test]
fn every_required_scenario_is_covered() {
let uncovered = coverage::uncovered();
assert!(
uncovered.is_empty(),
"these CLAUDE.md section 7 scenarios have no golden case: {uncovered:?}"
);
}
/// No case claims a scenario that is not on the mandatory list.
#[test]
fn no_case_claims_an_unknown_scenario() {
let unknown = coverage::unknown_claims();
assert!(
unknown.is_empty(),
"these `covers` tags are not in coverage::REQUIRED — a typo here means a \
case is testing something unnamed, which is how coverage quietly rots: {unknown:?}"
);
}
/// Every case passes.
///
/// Written as one test with all failures reported rather than one test per case:
/// a table that has drifted usually drifts in several places at once, and fixing
/// them one rebuild at a time hides the pattern.
#[test]
fn every_golden_case_passes() {
let cases = golden::all();
assert!(!cases.is_empty(), "the table set must not be empty");
let mut failures = Vec::new();
for case in &cases {
if let Some(reason) = case.check() {
failures.push(format!(" [{}] {}\n{reason}", case.covers, case.name));
}
}
assert!(
failures.is_empty(),
"{} of {} golden cases failed:\n{}",
failures.len(),
cases.len(),
failures.join("\n\n")
);
}
/// Case names are unique, so a failure message identifies exactly one case.
#[test]
fn case_names_are_unique() {
let mut seen = std::collections::BTreeMap::new();
for case in golden::all() {
let key = case.name.clone();
let n = seen.entry(key).or_insert(0);
*n += 1;
}
let dupes: Vec<&String> = seen.iter().filter(|(_, n)| **n > 1).map(|(k, _)| k).collect();
assert!(dupes.is_empty(), "duplicate case names make failure messages ambiguous: {dupes:?}");
}
/// The fixture list in `coverage` still matches reality.
///
/// `FIXTURES` is maintained by hand, so a rename that misses it would silently
/// drop the coverage claim. Rather than re-parsing the source, this checks the
/// shapes those fixtures are responsible for still hold.
#[test]
fn fixture_backed_scenarios_are_still_claimed() {
use blocker_core::lock::{LockLevel, MAX_WAIT_MS};
use blocker_core::model::{Schedule, ScheduleMode, Target};
// The 24-hour cap, asserted directly rather than trusting the fixture list.
for level in [LockLevel::Normal, LockLevel::Strict, LockLevel::Stubborn] {
let state = blocker_core::lock::LockState {
level,
schedules_locked: false,
pending: None,
cooldown_remaining: None,
attempts_today: 0,
keyholder_code: None,
};
assert!(state.worst_case_wait() <= MAX_WAIT_MS, "{level:?} exceeds the section 1.5.4 cap");
}
// And the never-block guarantee, restated here so removing the corresponding
// fixture cannot quietly drop the guarantee it protects.
assert!(
blocker_core::never_block::is_never_blocked("com.android.dialer")
|| blocker_core::never_block::is_never_blocked("plasmashell"),
"the never-block list lost its entries"
);
let _ = Schedule {
id: "unused".into(),
name: "unused".into(),
icon: None,
mode: ScheduleMode::Blocklist,
targets: vec![Target::App("x".into())],
conditions: vec![],
paused: true,
};
}
/// The cases that carry the section 5 idle budget must declare their wakeup.
///
/// `Wakeup::Any` is the default for a case that is about blocking behaviour, so
/// a missing wakeup assertion is not an error in general. It *is* an error for
/// the cases below: they exist to prove that the daemon sleeps, and a case that
/// silently stops asserting its wakeup is a lost guarantee rather than an
/// untested one. Named explicitly so deleting one is a deliberate act.
#[test]
fn idle_budget_cases_declare_their_wakeup() {
use crate::golden::Wakeup;
const MUST_ASSERT_WAKEUP: &[&str] = &[
"wakeup: a Wi-Fi schedule arms no timer, only an OS event can change it",
"wakeup: an inactive schedule still arms one for when it starts",
"start == end is a zero-length window, so it blocks nothing",
];
let cases = golden::all();
for name in MUST_ASSERT_WAKEUP {
let case = cases
.iter()
.find(|c| c.name == *name)
.unwrap_or_else(|| panic!("the idle-budget case {name:?} was deleted"));
assert_ne!(
case.expect.next_wakeup,
Wakeup::Any,
"{name:?} no longer asserts a wakeup, which is the whole point of it"
);
}
}
+588
View File
@@ -0,0 +1,588 @@
//! The golden cases themselves.
//!
//! Grouped by the CLAUDE.md section 7 scenario each group covers, so a table
//! reads as a description of behaviour rather than as an undifferentiated list.
//!
//! # Calendar anchors
//!
//! `MON` = 20731 = 2026-10-05, a Monday. Every date here is derived from that
//! one, and `weekday_indexing_is_monday_zero` in `blocker-core` asserts the
//! anchor itself. If weekday arithmetic breaks, that test fails and the rest of
//! these stop meaning what their names claim.
// Table modules are test surface: a missing case name or an empty never-block
// list means a broken test, not a runtime condition to handle gracefully.
#![allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
reason = "golden-table definitions: a missing case is a broken test, not a \
runtime condition to handle gracefully"
)]
use crate::golden::*;
use blocker_core::model::{Condition, Schedule, UsageWindow};
// 2026-10-05 is a Monday.
const MON: i64 = 20_731;
const TUE: i64 = MON + 1;
const SAT: i64 = MON + 5;
const SUN: i64 = MON + 6;
/// Section 7: overnight time ranges.
///
/// The case that matters is the early hours of the morning after: a window
/// starting Monday 22:00 must still be active at 03:00 **Tuesday**, governed by
/// the weekday it *started* on. A naive membership check reads 03:00 as Tuesday,
/// finds Tuesday absent, and a nightly schedule silently dies at midnight.
pub fn overnight_cases() -> Vec<Case> {
let nightly =
blocklist("s1", "Night", &[app("com.example.social")], &[time(&[0], at(22, 0), at(6, 0))]);
vec![
Case::new("overnight: active at 23:00 on the starting day", "7:overnight time ranges")
.with_schedules(vec![nightly.clone()])
.at(MON, 23, 0)
.blocks(&[("com.example.social", "s1")])
.wakes_in(7 * 60),
Case::new(
"overnight: STILL active at 03:00, governed by the day it started",
"7:overnight time ranges",
)
.with_schedules(vec![nightly.clone()])
.at(TUE, 3, 0)
.blocks(&[("com.example.social", "s1")])
.wakes_in(3 * 60),
Case::new(
"overnight: the end is exclusive at 06:00, and the next boundary is next Monday",
"7:overnight time ranges",
)
.with_schedules(vec![nightly.clone()])
.at(TUE, 6, 0)
.blocks(&[])
// Monday 22:00 from Tuesday 06:00 is 6 days 16 hours.
.wakes_in(9600),
Case::new(
"overnight: must NOT leak into a day that did not start it",
"7:overnight time ranges",
)
.with_schedules(vec![nightly])
.at(MON + 2, 3, 0),
Case::new(
"overnight across the week boundary: Sunday 22:00 covers Monday 02:00",
"7:overnight time ranges",
)
.with_schedules(vec![blocklist(
"s1",
"Sunday night",
&[app("com.example.social")],
&[time(&[6], at(22, 0), at(6, 0))],
)])
.at(SUN, 23, 0)
.blocks(&[("com.example.social", "s1")]),
Case::new(
"a window starting exactly at midnight is a full day, not an empty one",
"7:overnight time ranges",
)
.with_schedules(vec![blocklist(
"s1",
"All day",
&[app("com.example.social")],
&[time(ALL_DAYS, 0, at(23, 59))],
)])
.at(MON, 12, 0)
.blocks(&[("com.example.social", "s1")]),
Case::new(
"start == end is a zero-length window, so it blocks nothing",
"7:overnight time ranges",
)
.with_schedules(vec![blocklist(
"s1",
"Degenerate",
&[app("com.example.social")],
&[time(ALL_DAYS, at(9, 0), at(9, 0))],
)])
.at(MON, 9, 0)
// A window that can never match must not arm a timer either: a wakeup
// whose only purpose is to discover the window still does not match is a
// wakeup spent on nothing, and this section 5 budget is zero.
.never_wakes(),
]
}
/// Section 7: timezone changes and DST.
///
/// **Partial coverage, stated plainly.** `CivilTime` is an epoch value plus a UTC
/// offset — there is no IANA timezone database behind it, because the `time`
/// crate is not a section 3 dependency. So these cases pin the *observable*
/// rule, which is that a fixed local window stays fixed as the offset moves,
/// including across a DST transition. What cannot be expressed yet is a local
/// time that **does not exist** because the clock sprang forward, or is ambiguous
/// because it fell back. That gap is recorded in `docs/LIMITATIONS.md` and is
/// the strongest argument for adding a timezone-aware time type.
pub fn timezone_cases() -> Vec<Case> {
let work = blocklist(
"s1",
"Work hours",
&[app("com.example.social")],
&[time(WEEKDAYS, at(9, 0), at(17, 0))],
);
vec![
Case::new(
"timezone: 10:00 UTC is 12:00 local at UTC+2, so inside 09:00-17:00",
"7:timezone changes",
)
.with_schedules(vec![work.clone()])
.at(MON, 10, 0)
.with_offset(120)
.blocks(&[("com.example.social", "s1")]),
Case::new(
"timezone: 10:00 UTC at UTC+5 is 15:00 local, still inside 09:00-17:00",
"7:timezone changes",
)
.with_schedules(vec![work.clone()])
.at(MON, 10, 0)
.with_offset(300)
.blocks(&[("com.example.social", "s1")]),
Case::new(
"timezone: 10:00 UTC at UTC+7 is 17:00 local, the exclusive end",
"7:timezone changes",
)
.with_schedules(vec![work.clone()])
.at(MON, 10, 0)
.with_offset(420),
Case::new("timezone: a negative offset shifts the other way", "7:timezone changes")
.with_schedules(vec![work.clone()])
.at(MON, 10, 0)
.with_offset(-300),
Case::new(
"DST: 10:00 UTC stays inside 09:00-17:00 across a +1h spring-forward",
"7:DST changes",
)
.with_schedules(vec![work.clone()])
.at(MON, 10, 0)
.with_offset(60)
.blocks(&[("com.example.social", "s1")]),
Case::new(
"DST: a UTC-anchored window tracks the offset, not the wall clock",
"7:DST changes",
)
.with_schedules(vec![work.clone()])
.at(MON, 3, 0)
.with_offset(-180),
]
}
/// Section 7: strictest-wins.
///
/// An app named by several schedules is blocked, and each reason names the
/// schedule responsible — which is what lets the block screen say *why* rather
/// than the UI guessing.
pub fn strictest_wins_cases() -> Vec<Case> {
let work =
blocklist("s1", "Work", &[app("com.example.chat")], &[time(WEEKDAYS, at(9, 0), at(17, 0))]);
let bedtime = blocklist(
"s2",
"Bedtime",
&[app("com.example.chat"), app("com.example.video")],
&[time(ALL_DAYS, at(22, 0), at(7, 0))],
);
vec![
Case::new("strictest-wins: only the active schedule is attributed", "7:strictest-wins")
.with_schedules(vec![work, bedtime])
.at(MON, 23, 0)
.blocks(&[("com.example.chat", "s2"), ("com.example.video", "s2")])
.wakes_in(8 * 60),
Case::new("strictest-wins: a daytime schedule blocks on its own", "7:strictest-wins")
.with_schedules(vec![
blocklist(
"s1",
"Work",
&[app("com.example.chat")],
&[time(WEEKDAYS, at(9, 0), at(17, 0))],
),
blocklist(
"s2",
"Bedtime",
&[app("com.example.chat")],
&[time(ALL_DAYS, at(22, 0), at(7, 0))],
),
])
.at(MON, 10, 0)
.blocks(&[("com.example.chat", "s1")]),
Case::new(
"strictest-wins: the union means adding a schedule never unblocks",
"7:strictest-wins",
)
.with_schedules(vec![blocklist(
"s1",
"Always",
&[app("com.example.chat")],
&[time(ALL_DAYS, 0, at(23, 59))],
)])
.at(MON, 10, 0)
.blocks(&[("com.example.chat", "s1")]),
Case::new("a paused schedule contributes nothing at all", "7:strictest-wins")
.with_schedules(vec![Schedule {
paused: true,
..blocklist(
"s1",
"Paused",
&[app("com.example.chat")],
&[time(ALL_DAYS, 0, at(23, 59))],
)
}])
.at(MON, 10, 0),
]
}
/// Section 7: allowlist mode.
///
/// Allowlist blocks the *complement* of its own list, which needs the set of
/// installed apps. An empty inventory must fail open: a machine the engine knows
/// nothing about is not a machine it should act on.
pub fn allowlist_cases() -> Vec<Case> {
vec![
Case::new("allowlist: blocks everything installed that is not listed", "7:allowlist mode")
.with_schedules(vec![allowlist(
"s1",
"Only mail",
&[app("com.example.mail")],
&[time(ALL_DAYS, 0, at(23, 59))],
)])
.with_installed(&["com.example.mail", "com.example.chat", "com.example.browser"])
.at(MON, 12, 0)
.blocks(&[("com.example.browser", "s1"), ("com.example.chat", "s1")]),
Case::new("allowlist with no inventory blocks nothing, failing open", "7:allowlist mode")
.with_schedules(vec![allowlist(
"s1",
"Only mail",
&[app("com.example.mail")],
&[time(ALL_DAYS, 0, at(23, 59))],
)])
.at(MON, 12, 0),
Case::new(
"allowlist never blocks a never-blocked app even if unlisted",
"1.5.4:never-block list is unblockable",
)
.with_schedules(vec![allowlist(
"s1",
"Only mail",
&[app("com.example.mail")],
&[time(ALL_DAYS, 0, at(23, 59))],
)])
.with_installed(&["com.example.mail", "plasmashell", "explorer.exe"])
.at(MON, 12, 0)
.blocks(&[]),
Case::new(
"an allowlist naming a website is rejected — allowlists are apps-only",
"7:invalid condition combos",
)
.with_schedules(vec![allowlist(
"s1",
"Broken",
&[website("example.com")],
&[time(ALL_DAYS, 0, at(23, 59))],
)])
.at(MON, 12, 0)
.invalid(),
]
}
/// Section 1.2: the forbidden condition combinations, each one its own case so a
/// missing pair is visible rather than hidden inside a combined assertion.
pub fn condition_guard_cases() -> Vec<Case> {
let t = time(WEEKDAYS, at(9, 0), at(17, 0));
let u = usage_limit(30, UsageWindow::Daily);
let l = launch_count(3, UsageWindow::Daily);
let loc = location(52.52, 13.405, 500, false);
let targets = [app("com.example.chat")];
let pairs: &[(&'static str, Condition, Condition)] = &[
("1.2:time+usage rejected", t.clone(), u.clone()),
("1.2:time+launch rejected", t.clone(), l.clone()),
("1.2:usage+launch rejected", u.clone(), l.clone()),
("1.2:usage+location rejected", u, loc.clone()),
("1.2:launch+location rejected", l, loc),
];
let mut cases = Vec::new();
for (tag, first, second) in pairs {
cases.push(
Case::new(&format!("section 1.2 forbids pairing these two conditions [{tag}]"), tag)
.with_schedules(vec![blocklist(
"s1",
"Rejected",
&targets,
&[first.clone(), second.clone()],
)])
.invalid(),
);
}
// Order must not matter — validation is not a directional check.
cases.push(
Case::new(
"section 1.2 rejects the same pair in the opposite order",
"7:invalid condition combos",
)
.with_schedules(vec![blocklist(
"s1",
"Reversed",
&targets,
&[launch_count(3, UsageWindow::Daily), time(WEEKDAYS, at(9, 0), at(17, 0))],
)])
.invalid(),
);
// A permitted combination, so the guards are not simply rejecting everything.
cases.push(
Case::new("section 1.2 permits Wi-Fi with a time window", "7:invalid condition combos")
.with_schedules(vec![blocklist(
"s1",
"Permitted",
&targets,
&[time(WEEKDAYS, at(9, 0), at(17, 0)), wifi("CoffeeShop")],
)])
.with_ssid("CoffeeShop")
.at(MON, 10, 0)
.blocks(&[("com.example.chat", "s1")]),
);
// Section 6.4: location is mobile-only, and desktop must reject rather than
// accept-and-never-fire.
cases.push(
Case::new(
"section 6.4 rejects a location condition on desktop",
"7:invalid condition combos",
)
.with_schedules(vec![blocklist(
"s1",
"Geo",
&targets,
&[location(52.52, 13.405, 500, false)],
)])
.on_platform(blocker_core::model::Platform::Linux)
.invalid(),
);
cases
}
/// Section 1.2: usage-limit and launch-count conditions.
pub fn usage_and_launch_cases() -> Vec<Case> {
let targets = [app("com.example.chat")];
vec![
Case::new(
"usage limit: not yet reached, so nothing is blocked",
"7:invalid condition combos",
)
.with_schedules(vec![blocklist(
"s1",
"Limit",
&targets,
&[usage_limit(30, UsageWindow::Daily)],
)])
.with_daily_minutes("com.example.chat", 29)
.at(MON, 12, 0)
.blocks(&[]),
Case::new(
"usage limit: reached exactly, so the app is blocked",
"7:invalid condition combos",
)
.with_schedules(vec![blocklist(
"s1",
"Limit",
&targets,
&[usage_limit(30, UsageWindow::Daily)],
)])
.with_daily_minutes("com.example.chat", 30)
.at(MON, 12, 0)
.blocks(&[("com.example.chat", "s1")])
.wakes_in(12 * 60),
Case::new("launch count: the Nth launch blocks", "7:invalid condition combos")
.with_schedules(vec![blocklist(
"s1",
"Launches",
&targets,
&[launch_count(3, UsageWindow::Hourly)],
)])
.with_daily_launches("com.example.chat", 3)
.at(MON, 12, 0)
.blocks(&[("com.example.chat", "s1")]),
]
}
/// Section 1.2: Wi-Fi and location.
pub fn wifi_and_location_cases() -> Vec<Case> {
let targets = [app("com.example.chat")];
vec![
Case::new("wifi: blocks on the named SSID", "7:invalid condition combos")
.with_schedules(vec![blocklist(
"s1",
"Not at the cafe",
&targets,
&[wifi("CoffeeShop")],
)])
.with_ssid("CoffeeShop")
.at(MON, 12, 0)
.blocks(&[("com.example.chat", "s1")]),
Case::new("wifi: does nothing on a different SSID", "7:invalid condition combos")
.with_schedules(vec![blocklist(
"s1",
"Not at the cafe",
&targets,
&[wifi("CoffeeShop")],
)])
.with_ssid("HomeWifi")
.at(MON, 12, 0),
Case::new("location: blocks inside the radius", "7:invalid condition combos")
.with_schedules(vec![blocklist(
"s1",
"At the office",
&targets,
&[location(52.52, 13.405, 500, false)],
)])
.with_location(52.5205, 13.4055)
.at(MON, 12, 0)
.blocks(&[("com.example.chat", "s1")]),
Case::new("location: outside the radius does not block", "7:invalid condition combos")
.with_schedules(vec![blocklist(
"s1",
"At the office",
&targets,
&[location(52.52, 13.405, 500, false)],
)])
.with_location(48.8566, 2.3522)
.at(MON, 12, 0),
]
}
/// The zero-idle-wakeup budget, as an assertion.
///
/// These are the cases that protect the section 5 idle-CPU number. The subtle one
/// is `inactive_but_pending`: a schedule that is not active *now* must still arm
/// a wakeup for when it starts, or the daemon sleeps through the moment it is
/// meant to begin blocking.
pub fn wakeup_cases() -> Vec<Case> {
vec![
Case::new(
"wakeup: a Wi-Fi schedule arms no timer, only an OS event can change it",
"7:strict mode lock rules",
)
.with_schedules(vec![blocklist(
"s1",
"Not at the cafe",
&[app("com.example.chat")],
&[wifi("CoffeeShop")],
)])
.at(MON, 12, 0)
.never_wakes(),
Case::new(
"wakeup: an inactive schedule still arms one for when it starts",
"7:strict mode lock rules",
)
.with_schedules(vec![blocklist(
"s1",
"Weekends",
&[app("com.example.chat")],
&[time(&[0, 1, 2, 3, 4], at(9, 0), at(17, 0))],
)])
// Saturday: not a start day, so nothing is blocked now...
.at(SAT, 12, 0)
.blocks(&[])
// ...but Monday 09:00 is the next boundary, 45 hours away, and must be
// armed or the schedule silently never fires.
.wakes_in(45 * 60),
]
}
/// Section 7: the never-block list, as tables rather than fixtures.
///
/// The engine consults the list *after* schedule evaluation, so a schedule
/// naming a protected target still blocks its other targets while the protected
/// one cannot be blocked. Cases here prove both halves of that.
pub fn never_block_cases() -> Vec<Case> {
vec![
Case::new(
"never-block: a protected target cannot be blocked, but its siblings can",
"1.5.4:never-block list is unblockable",
)
.with_schedules(vec![blocklist(
"s1",
"Sweeping",
&[app("plasmashell"), app("com.example.chat")],
&[time(ALL_DAYS, 0, at(23, 59))],
)])
.at(MON, 12, 0)
.blocks(&[("com.example.chat", "s1")]),
// Derived from the list this build actually has. The never-block list is
// per-platform (section 1.5.4 names the dialler on mobile and the shell
// on desktop), so a hardcoded Android package asserts nothing on a Linux
// build — which is exactly how a table stops testing its own name.
Case::new(
"never-block: matching is substring-based, so a variant name is covered",
"1.5.4:never-block list is unblockable",
)
.with_schedules(vec![blocklist(
"s1",
"Variant",
&[app(&variant_of_a_protected_name())],
&[time(ALL_DAYS, 0, at(23, 59))],
)])
.at(MON, 12, 0)
.blocks(&[]),
]
}
/// A protected name with something appended, to exercise substring matching.
///
/// Panics if the list is empty, which is itself a bug: an empty list would mean
/// a device could be locked out of its own dialler or shell.
fn variant_of_a_protected_name() -> String {
let first = blocker_core::never_block::NEVER_BLOCK
.first()
.expect("the never-block list must not be empty");
format!("{first}.variant")
}
/// Section 1.5.4: the 24-hour worst case, as a table.
///
/// The timer arithmetic itself is in `tests/src/purity.rs`, because proving a cap
/// needs arithmetic rather than a decision. What belongs here is the *decision*
/// consequence: a Stubborn lock still yields an ordinary, explainable decision,
/// and the engine reports the schedule and end time the block screen will show.
pub fn lock_rule_cases() -> Vec<Case> {
vec![
Case::new(
"lock rules: under a lock the schedule still blocks, and still explains itself",
"7:strict mode lock rules",
)
.with_schedules(vec![blocklist(
"s1",
"Evening Focus",
&[app("com.example.chat")],
&[time(ALL_DAYS, at(22, 0), at(6, 0))],
)])
.at(MON, 23, 0)
.blocks(&[("com.example.chat", "s1")])
.wakes_in(7 * 60),
Case::new(
"lock rules: a locked schedule's end time is the window boundary, not the lock",
"1.5.4:worst case 24h",
)
.with_schedules(vec![blocklist(
"s1",
"Bounded",
&[app("com.example.chat")],
&[time(ALL_DAYS, at(22, 0), at(6, 0))],
)])
.at(MON, 22, 0)
.blocks(&[("com.example.chat", "s1")])
.wakes_in(8 * 60),
]
}