Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1318515103 | |||
| f05836f528 | |||
| 92afdc3bf6 | |||
| 9a5c529a8e |
Generated
+116
@@ -2,6 +2,12 @@
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
||||
|
||||
[[package]]
|
||||
name = "blocker-core"
|
||||
version = "0.1.0"
|
||||
@@ -11,6 +17,7 @@ name = "blocker-daemon"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"blocker-core",
|
||||
"blocker-storage",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -27,6 +34,14 @@ dependencies = [
|
||||
"blocker-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blocker-storage"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"blocker-core",
|
||||
"rusqlite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blocker-tests"
|
||||
version = "0.1.0"
|
||||
@@ -37,3 +52,104 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "blocker-ui"
|
||||
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"
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
resolver = "2"
|
||||
members = [
|
||||
"core",
|
||||
"storage",
|
||||
"tests",
|
||||
"desktop/daemon",
|
||||
"desktop/ui",
|
||||
|
||||
@@ -16,7 +16,7 @@ enforces anything yet. No platform has a working build.
|
||||
| Milestone | State |
|
||||
|---|---|
|
||||
| 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 |
|
||||
| M3-Android | 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
|
||||
storage/ SQLite persistence (rusqlite, bundled), fail-open recovery
|
||||
tests/ golden decision tables, and the engine purity check
|
||||
desktop/daemon/ long-lived observer and enforcer (5 MB budget)
|
||||
desktop/ui/ block-screen window, launched on demand
|
||||
@@ -106,9 +107,10 @@ scripts/ ci.sh, size-check.sh
|
||||
| Daemon binary | ≤ 5 MB stripped |
|
||||
| APK size | ≤ 10 MB |
|
||||
|
||||
Currently measured: **0.279 MB** stripped daemon — a placeholder that prints two
|
||||
lines. The meaningful number comes in M2-Linux. Enforced in CI from the first
|
||||
commit by `scripts/size-check.sh`.
|
||||
Currently measured: **1.364 MB** stripped daemon, which includes bundled SQLite.
|
||||
Of the 5 MB budget that leaves 3.8 MB for D-Bus, X11 and a UI toolkit — a real
|
||||
margin rather than a comfortable one. Enforced in CI from the first commit by
|
||||
`scripts/size-check.sh`.
|
||||
|
||||
## Licence
|
||||
|
||||
|
||||
+39
-18
@@ -214,6 +214,7 @@ pub fn evaluate(input: &EngineInput<'_>) -> Decision {
|
||||
ScheduleMode::Blocklist => {
|
||||
for target in &schedule.targets {
|
||||
let reason = BlockReason {
|
||||
target: target.clone(),
|
||||
schedule_id: schedule.id.clone(),
|
||||
schedule_name: schedule.name.clone(),
|
||||
condition: active[0],
|
||||
@@ -245,6 +246,7 @@ pub fn evaluate(input: &EngineInput<'_>) -> Decision {
|
||||
continue;
|
||||
}
|
||||
blocked_apps.push(BlockReason {
|
||||
target: Target::App(id.clone()),
|
||||
schedule_id: schedule.id.clone(),
|
||||
schedule_name: schedule.name.clone(),
|
||||
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
|
||||
/// window is not currently active.
|
||||
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 today = weekday(now);
|
||||
|
||||
if time_window_contains(window, now) {
|
||||
// Currently active: the end depends on whether we're in the early or
|
||||
// late part of an overnight window.
|
||||
let end_today = if window.is_overnight() && minute < window.end {
|
||||
// Early part — ends later today.
|
||||
window.end
|
||||
// Currently active. Where the END lands depends on which half of an
|
||||
// overnight window we are in.
|
||||
//
|
||||
// At 23:00 in a 22:00-06:00 window the end is 06:00 *tomorrow*, 420
|
||||
// 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 {
|
||||
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 {
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
// 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;
|
||||
for day_offset in 0..=7 {
|
||||
let candidate_day = (today as i32 + day_offset) % 7;
|
||||
if !window.days.contains(&(candidate_day as u8)) {
|
||||
for day_offset in 0..=7i64 {
|
||||
let candidate_day = ((today as i64 + day_offset) % 7) as u8;
|
||||
if !window.days.contains(&candidate_day) {
|
||||
continue;
|
||||
}
|
||||
let mut delta = i64::from(window.start) - i64::from(minute);
|
||||
if window.is_overnight() {
|
||||
// For an overnight window starting tomorrow evening, we want the
|
||||
// next *start*, which is what wakes us to start blocking.
|
||||
delta = i64::from(window.start) - i64::from(minute);
|
||||
}
|
||||
let mut delta = day_offset * 24 * 60 + i64::from(window.start) - i64::from(minute);
|
||||
if delta < 0 {
|
||||
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
|
||||
/// stop firing", this also covers a condition that is currently false — a time
|
||||
/// window that has not started yet still has a start, and that start is exactly
|
||||
/// when the daemon must wake up to begin blocking.
|
||||
/// when the daemon must wake to begin blocking.
|
||||
///
|
||||
/// Returns `None` when nothing about the schedule is time-dependent, which is
|
||||
/// the case for Wi-Fi and location schedules: only an OS event can change them,
|
||||
|
||||
+30
-5
@@ -123,12 +123,23 @@ pub struct Schedule {
|
||||
|
||||
/// Why a target is currently blocked, and until when.
|
||||
///
|
||||
/// Section 1.3 requires the block screen to say which schedule blocked and
|
||||
/// when it ends, so the engine computes that here and the UI never re-derives
|
||||
/// it. Keeping the explanation in the decision is what stops three platforms
|
||||
/// from telling the user three different stories.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
/// Section 1.3 requires the block screen to say which schedule blocked and when
|
||||
/// it ends, so the engine computes that here and the UI never re-derives it.
|
||||
/// Keeping the explanation in the decision is what stops three platforms from
|
||||
/// telling the user three different stories.
|
||||
///
|
||||
/// 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 {
|
||||
/// The specific target being blocked, not merely the schedule.
|
||||
pub target: Target,
|
||||
pub schedule_id: String,
|
||||
pub schedule_name: String,
|
||||
/// The specific condition that fired, named for display.
|
||||
@@ -138,6 +149,20 @@ pub struct BlockReason {
|
||||
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.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ValidationError {
|
||||
|
||||
@@ -16,4 +16,5 @@ path = "src/main.rs"
|
||||
# section 3 requires every crate to justify its size cost. The runtime pieces
|
||||
# (zbus for D-Bus, x11rb for X11) arrive in M2-Linux, each with an ADR entry.
|
||||
[dependencies]
|
||||
blocker-core = { path = "../../core" }
|
||||
blocker-core = { path = "../../core" }
|
||||
blocker-storage = { path = "../../storage" }
|
||||
+71
-11
@@ -2,16 +2,16 @@
|
||||
//!
|
||||
//! The daemon observes the OS and enforces the decisions `blocker-core` makes.
|
||||
//! It contains **no decision logic of its own** (CLAUDE.md section 2): if the
|
||||
//! daemon ever decides that something should be blocked, the Android and
|
||||
//! desktop builds will drift and the golden tables stop being a parity contract.
|
||||
//! daemon ever decides that something should be blocked, the Android and desktop
|
||||
//! builds will drift and the golden tables stop being a parity contract.
|
||||
//!
|
||||
//! # Shape
|
||||
//!
|
||||
//! Two processes, deliberately:
|
||||
//!
|
||||
//! - the **daemon** — small, long-lived, stays resident, holds the block window
|
||||
//! machinery and the engine connection. This is the binary with the 5 MB
|
||||
//! stripped budget (section 5).
|
||||
//! - the **daemon** — small, long-lived, holds the block window machinery and
|
||||
//! the engine connection. This is the binary with the 5 MB stripped budget
|
||||
//! (section 5).
|
||||
//! - the **UI** (`blocker-ui`) — a separate process launched to show a block
|
||||
//! screen, which exits when the block lifts.
|
||||
//!
|
||||
@@ -20,14 +20,74 @@
|
||||
//!
|
||||
//! # Event loop
|
||||
//!
|
||||
//! M2-Linux replaces this placeholder with a loop that blocks on exactly three
|
||||
//! things: a focus-change event from the active
|
||||
//! [`ForegroundWatcher`](../../blocker_core/index.html) backend, the
|
||||
//! M2-Linux replaces the startup path below with a loop that blocks on exactly
|
||||
//! three things: a focus-change event from the active
|
||||
//! [`ForegroundWatcher`](../../blocker_platform_linux/index.html) backend, the
|
||||
//! `next_wakeup` instant from the engine, and IPC from the UI. There is no
|
||||
//! polling tick anywhere — an idle daemon takes zero wakeups, which is how the
|
||||
//! section 5 idle-CPU budget is met.
|
||||
//!
|
||||
//! # 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() {
|
||||
println!("blocker-daemon {} (M0 placeholder)", env!("CARGO_PKG_VERSION"));
|
||||
println!("enforcement lands in M2-Linux; see docs/DECISIONS.md for the ADR log");
|
||||
use std::path::PathBuf;
|
||||
use std::process::ExitCode;
|
||||
|
||||
/// `~/.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
@@ -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 |
|
||||
| `lock` | lock levels, the friction ladder's timing, monotonic anti-tamper |
|
||||
| `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
|
||||
and reviewed as text so it can be reviewed before it is compiled.
|
||||
`blocker-storage` is a sibling crate, not a submodule. It depends on
|
||||
`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
|
||||
|
||||
|
||||
+61
-22
@@ -25,7 +25,7 @@ which parts to trust.
|
||||
| [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 |
|
||||
| [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 |
|
||||
| [6](#adr-6) | Desktop UI toolkit — Slint vs egui | **Proposed, needs owner** |
|
||||
| [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 |
|
||||
| [14](#adr-14) | Linux enforcement backend order: KWin, X11, process | 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** |
|
||||
|
||||
---
|
||||
@@ -108,24 +108,46 @@ it is supposed to start blocking.
|
||||
---
|
||||
|
||||
<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
|
||||
requires lock and Strict state to live in the core database rather than in
|
||||
editable plaintext config.
|
||||
|
||||
**Decision.** `rusqlite` with `bundled`, storing schedules, lock state, and usage
|
||||
counters in one file.
|
||||
**Decision.** `rusqlite` with `bundled`, in a **separate `blocker-storage` crate**
|
||||
that depends on `blocker-core`, never the reverse.
|
||||
|
||||
**Consequences.** The schema is written and reviewed in
|
||||
`core/src/storage.rs` before the crate is added, so it can be reviewed as text.
|
||||
Bundling compiles SQLite from source, which costs build time and binary size but
|
||||
removes a system dependency — a real consideration on the machines this ships to.
|
||||
**Why the separate crate.** 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 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
|
||||
an open item, not a settled fact.
|
||||
Both crates sit in the same layer of the section 2 diagram: storage is shared by
|
||||
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>
|
||||
## 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
|
||||
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
|
||||
`uniffi`, so TOML would add a crate for no gain, and section 3 makes every crate
|
||||
justify its cost. JSON is also what a review diff reads most naturally.
|
||||
**Decision.** A builder DSL in Rust. `tests/src/golden.rs` holds the format and
|
||||
harness, `tests/src/tables.rs` the cases, `tests/src/coverage.rs` the coverage
|
||||
bookkeeping, `tests/src/run.rs` the runner.
|
||||
|
||||
**Not yet decided.** The parse layer needs serde, which is a dependency decision
|
||||
requiring the owner's sign-off. The format itself is written down in
|
||||
`tests/src/golden.rs` now; the parse arrives in M1 with ADR-4.
|
||||
**Rationale.** 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
|
||||
`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
@@ -177,17 +177,23 @@ not mistaken for platform facts.
|
||||
- **`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
|
||||
spec assumes because of the `/proc/connectors` gap above.
|
||||
- **`rusqlite` has not been added.** The schema is written and reviewed in
|
||||
`core/src/storage.rs` (ADR-4) but storage has no I/O behind it yet, so nothing
|
||||
persists. A restart loses all state.
|
||||
- **`rusqlite` vs system-linked SQLite is unresolved** (ADR-4). Bundled is built
|
||||
and costs ~1.1 MB of the 5 MB daemon budget. System-linking would reclaim most
|
||||
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
|
||||
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
|
||||
the Normal level unconditionally.
|
||||
- **The golden tables cannot be parsed.** The format is specified in
|
||||
`tests/src/golden.rs`; parsing needs serde, which is a dependency decision
|
||||
awaiting sign-off (ADR-13, ADR-16). The behavioural coverage that exists today
|
||||
is hand-written unit tests in the same spirit.
|
||||
- **DST coverage is partial, and the boundary is stated.** `CivilTime` is an
|
||||
epoch value plus a UTC offset, with no IANA timezone database behind it — the
|
||||
`time` crate is not a section 3 dependency (ADR-16). The golden tables pin the
|
||||
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
|
||||
than the schedule's own target set, until targets carry usage keys.
|
||||
- **Windows is entirely unimplemented** and cannot be compiled or tested on the
|
||||
|
||||
+14
-5
@@ -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 |
|
||||
|---|---|---|---|
|
||||
| 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 | — |
|
||||
| Idle CPU | ~0% | not measured | — |
|
||||
| Idle RAM (daemon) | ≤ 15 MB RSS | not measured | — |
|
||||
| Idle wakeups | ≤ 1/min | not measured | — |
|
||||
| APK size | ≤ 10 MB | not measured | — |
|
||||
|
||||
The binary figure is real but nearly meaningless right now: the daemon is two
|
||||
`println!` lines. The meaningful measurement comes in M2-Linux once enforcement
|
||||
exists, and the real risk is at that point — D-Bus, X11, and a UI toolkit are
|
||||
where 5 MB would actually be threatened.
|
||||
**What the 1.364 MB contains, and why it matters.** That figure is with bundled
|
||||
SQLite linked in and the daemon actually opening a database — not the 0.279 MB
|
||||
placeholder from M0, which was two `println!` lines and excluded SQLite entirely
|
||||
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
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -1,242 +1,423 @@
|
||||
//! The golden decision tables (CLAUDE.md section 7).
|
||||
//!
|
||||
//! A table is a list of cases. Each case is schedules plus an input plus an
|
||||
//! expected decision, and running the same table against the engine must
|
||||
//! reproduce the expectation exactly.
|
||||
//! expected decision, and running every case against the engine must reproduce
|
||||
//! 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
|
||||
//! `serde_json` is already in the dependency tree via `rusqlite` and `uniffi`
|
||||
//! (both listed in CLAUDE.md section 3), so TOML would add a crate for no gain.
|
||||
//! Unit tests check the engine against itself. These check that *every platform*
|
||||
//! agrees with the engine. Each platform implements a mapping from its own app
|
||||
//! 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
|
||||
//! {
|
||||
//! "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"
|
||||
//! }
|
||||
//! }
|
||||
//! ]
|
||||
//! }
|
||||
//! ```
|
||||
//! # The rule that keeps the tables meaningful
|
||||
//!
|
||||
//! # 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
|
||||
//! exists so M1 implements against a fixed format rather than retrofitting one.
|
||||
//! `serde::Deserialize` is deliberately not derived yet — adding serde is a
|
||||
//! dependency decision (CLAUDE.md section 0 rule 3) and is deferred to M1,
|
||||
//! where it arrives with `rusqlite` under one ADR entry.
|
||||
//! So a PR that changes engine behaviour is reviewed on one question: is there a
|
||||
//! case added or updated in the same commit? If not, the change is undocumented
|
||||
//! behaviour.
|
||||
//!
|
||||
//! # The clock-tampering exception
|
||||
//! # Why these are Rust, not JSON
|
||||
//!
|
||||
//! Most cases are pure data. Clock tampering is not: proving that moving the
|
||||
//! system date never shortens a lock requires actually moving a clock, which a
|
||||
//! data file cannot express. Those cases live in [`crate::purity`] as code
|
||||
//! fixtures instead, and the two coexist deliberately — the tables describe
|
||||
//! decisions, the fixtures describe the passage of time.
|
||||
//! Originally specified as JSON on the reasoning that `serde_json` would already
|
||||
//! be in the tree. That reasoning was wrong: `rusqlite` does not pull `serde` by
|
||||
//! default, and `uniffi` has not landed yet, so nothing in the graph provides it.
|
||||
//! `serde` is not in CLAUDE.md section 3, and section 0 rule 3 requires asking
|
||||
//! 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.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Table {
|
||||
/// Which concern this table covers, e.g. `overnight`.
|
||||
pub concern: String,
|
||||
/// The cases in the table.
|
||||
pub cases: Vec<Case>,
|
||||
/// Sentinel meaning "the platform may sleep forever" in an expectation.
|
||||
///
|
||||
/// `"none"` as a string was ambiguous with a null; a distinct type is not.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Wakeup {
|
||||
/// Assert nothing about the wakeup.
|
||||
///
|
||||
/// 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.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Case {
|
||||
/// Human-readable, and the primary test failure message.
|
||||
pub name: String,
|
||||
/// Which CLAUDE.md section 7 scenario this covers, so coverage is auditable
|
||||
/// rather than assumed. Checked against [`MANDATORY_COVERAGE`].
|
||||
pub covers: String,
|
||||
/// The schedules under test.
|
||||
pub schedules: Vec<ScheduleSpec>,
|
||||
/// The input to the engine.
|
||||
pub input: InputSpec,
|
||||
/// The decision that must result.
|
||||
pub expect: ExpectSpec,
|
||||
}
|
||||
|
||||
/// A schedule as it appears in a table.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ScheduleSpec {
|
||||
/// Stable id, referenced by `BlockedSpec::schedule`.
|
||||
pub id: String,
|
||||
/// Display name, shown on the block screen.
|
||||
pub name: String,
|
||||
/// `"Blocklist"` or `"Allowlist"`.
|
||||
pub mode: String,
|
||||
/// What this schedule targets.
|
||||
pub targets: Vec<TargetSpec>,
|
||||
/// Its conditions. At least one must hold for the schedule to be active.
|
||||
pub conditions: Vec<ConditionSpec>,
|
||||
/// Defaults to false when absent from JSON.
|
||||
pub paused: bool,
|
||||
}
|
||||
|
||||
/// A target, tagged by type so the JSON is self-describing.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum TargetSpec {
|
||||
/// An app, by platform-native identifier.
|
||||
App {
|
||||
/// Package name on Android, desktop-file id or exe path elsewhere.
|
||||
app: String,
|
||||
},
|
||||
/// A registrable domain. Subdomains are implied.
|
||||
Website {
|
||||
/// e.g. `example.com`.
|
||||
website: String,
|
||||
},
|
||||
/// A case-insensitive substring matched against the URL.
|
||||
Keyword {
|
||||
/// The substring to match.
|
||||
keyword: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// A condition, tagged by type.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ConditionSpec {
|
||||
/// Block while local time is inside the window.
|
||||
Time {
|
||||
/// Weekdays on which the window **starts**. Monday = 0, matching
|
||||
/// `time::Weekday`.
|
||||
days: Vec<u8>,
|
||||
/// `HH:MM`. When `start > end` the window spans midnight — there is
|
||||
/// deliberately no `crosses_midnight` flag, because the comparison
|
||||
/// already encodes it and a second field is a second way to be wrong.
|
||||
start: String,
|
||||
/// `HH:MM`. See `start`.
|
||||
end: String,
|
||||
},
|
||||
/// Block after a foreground-time threshold.
|
||||
UsageLimit {
|
||||
/// Threshold in minutes.
|
||||
limit_minutes: u32,
|
||||
/// `"Daily"` or `"Hourly"`.
|
||||
window: String,
|
||||
},
|
||||
/// Block after a launch-count threshold.
|
||||
LaunchCount {
|
||||
/// Threshold count.
|
||||
limit: u32,
|
||||
/// `"Daily"` or `"Hourly"`.
|
||||
window: String,
|
||||
},
|
||||
/// Block while connected to a named network.
|
||||
WifiSsid {
|
||||
/// The SSID to match exactly.
|
||||
ssid: String,
|
||||
},
|
||||
/// Block inside or outside a radius. Mobile only (section 6.4).
|
||||
Location {
|
||||
/// Circle centre latitude in degrees.
|
||||
latitude: f64,
|
||||
/// Circle centre longitude in degrees.
|
||||
longitude: f64,
|
||||
/// Radius in metres.
|
||||
radius_meters: u32,
|
||||
/// Invert the test: block when *outside* the radius. Defaults false.
|
||||
block_when_outside: bool,
|
||||
},
|
||||
}
|
||||
|
||||
/// The engine input for a case.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct InputSpec {
|
||||
/// RFC 3339 with an explicit offset, e.g. `2026-10-06T03:00:00+02:00`.
|
||||
/// The offset is mandatory so a DST case is unambiguous.
|
||||
pub civil_time: String,
|
||||
/// Monotonic milliseconds since boot. Absent means 0.
|
||||
pub monotonic_ms: u64,
|
||||
/// Boot id. Absent means 0.
|
||||
pub boot_id: u64,
|
||||
/// Accumulated usage.
|
||||
pub usage: UsageSpec,
|
||||
/// Platform-observed context.
|
||||
pub context: ContextSpec,
|
||||
}
|
||||
|
||||
/// Usage counters for a case. All fields default to empty.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct UsageSpec {
|
||||
/// Foreground minutes per target, daily bucket.
|
||||
pub daily_minutes: BTreeMap<String, u32>,
|
||||
/// Foreground minutes per target, hourly bucket.
|
||||
pub hourly_minutes: BTreeMap<String, u32>,
|
||||
/// Launch counts per target, daily bucket.
|
||||
pub daily_launches: BTreeMap<String, u32>,
|
||||
/// Launch counts per target, hourly bucket.
|
||||
pub hourly_launches: BTreeMap<String, u32>,
|
||||
}
|
||||
|
||||
/// Platform context for a case.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ContextSpec {
|
||||
/// Current Wi-Fi SSID, if any.
|
||||
pub current_ssid: Option<String>,
|
||||
/// Current `(latitude, longitude)`, if any.
|
||||
pub current_location: Option<(f64, f64)>,
|
||||
}
|
||||
|
||||
/// The decision a case expects.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ExpectSpec {
|
||||
/// Apps that must be blocked, each attributed to a schedule.
|
||||
pub blocked_apps: Vec<BlockedSpec>,
|
||||
/// Websites and keywords that must be blocked.
|
||||
pub blocked_domains: Vec<BlockedSpec>,
|
||||
/// RFC 3339, or the literal `none` when the platform may sleep forever.
|
||||
pub next_wakeup: String,
|
||||
}
|
||||
|
||||
/// One expected blocked item.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BlockedSpec {
|
||||
/// One expected blocked item, attributed to a schedule.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Blocked {
|
||||
/// The target that must be blocked.
|
||||
pub app: String,
|
||||
/// The id of the schedule that must block it.
|
||||
pub schedule: String,
|
||||
}
|
||||
|
||||
/// Every scenario in [`MANDATORY_COVERAGE`] must be claimed by at least one
|
||||
/// `covers` field across the tables, and `coverage.rs` asserts it — so a
|
||||
/// scenario cannot silently lose its test.
|
||||
pub const MANDATORY_COVERAGE: &[&str] = &[
|
||||
"7:overnight time ranges",
|
||||
"7:DST changes",
|
||||
"7:timezone changes",
|
||||
"7:clock tampering",
|
||||
"7:strictest-wins",
|
||||
"7:allowlist mode",
|
||||
"7:invalid condition combos",
|
||||
"7:strict mode lock rules",
|
||||
// The section 1.2 combination table, itemised so a missing pair is visible.
|
||||
"1.2:time+usage rejected",
|
||||
"1.2:time+launch rejected",
|
||||
"1.2:usage+launch rejected",
|
||||
"1.2:usage+location rejected",
|
||||
"1.2:launch+location rejected",
|
||||
// Section 1.5.4 safety guarantees.
|
||||
"1.5.4:never-block list is unblockable",
|
||||
"1.5.4:worst case 24h",
|
||||
];
|
||||
/// The decision a case expects.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Expect {
|
||||
/// Apps that must be blocked. Order-independent; the engine's union order
|
||||
/// is not part of the contract.
|
||||
pub blocked_apps: Vec<String>,
|
||||
/// Schedules expected to attribute each block. Parallel to `blocked_apps`.
|
||||
pub blocked_by: Vec<String>,
|
||||
/// Websites and keywords that must be blocked.
|
||||
pub blocked_domains: Vec<String>,
|
||||
/// When the platform must wake up next, if ever.
|
||||
pub next_wakeup: Wakeup,
|
||||
}
|
||||
|
||||
impl Default for Expect {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
blocked_apps: Vec::new(),
|
||||
blocked_by: Vec::new(),
|
||||
blocked_domains: Vec::new(),
|
||||
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
@@ -5,33 +5,38 @@
|
||||
//!
|
||||
//! # Why this is a contract, not a test suite
|
||||
//!
|
||||
//! Section 7 requires that the same table runs against the core and serves as
|
||||
//! the parity contract for all platforms. Android, Linux and Windows each
|
||||
//! implement a mapping from a platform app identity to a `Target`, but they all
|
||||
//! hand the same [`blocker_core`] engine the same schedule set and get back the
|
||||
//! same decision. A platform crate runs this table against its own mapping, so
|
||||
//! if Linux disagrees with Android the build fails rather than the user filing
|
||||
//! a bug.
|
||||
//! Unit tests check the engine against itself. These check that *every platform*
|
||||
//! agrees with the engine. Android, Linux and Windows each map their own app
|
||||
//! identity — package name, desktop-file id, exe path — onto a
|
||||
//! [`blocker_core::model::Target`], and every case 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.
|
||||
//!
|
||||
//! # The rule that keeps it honest
|
||||
//!
|
||||
//! **A case is added before the engine changes.** If a table were written after
|
||||
//! the code, it would only record current behaviour and could never catch a
|
||||
//! regression — it would be a transcript, not a specification. Reviewing a PR
|
||||
//! that changes engine behaviour therefore means checking that a case was added
|
||||
//! or updated in the same commit.
|
||||
//! **A case is added before the engine changes.** A table written after the code
|
||||
//! records current behaviour and can never catch a regression — it would be a
|
||||
//! transcript, not a specification. Reviewing a PR that changes engine behaviour
|
||||
//! therefore means checking that a case was added or updated in the same commit.
|
||||
//!
|
||||
//! # Layout
|
||||
//!
|
||||
//! - `golden/` — the tables themselves, one JSON file per concern
|
||||
//! - [`purity`] — a mechanical check that the core has not gained I/O
|
||||
//!
|
||||
//! The mandatory case list is in [`golden::MANDATORY_COVERAGE`].
|
||||
//! - [`golden`] — the case format, the harness, and the builder DSL
|
||||
//! - [`tables`] — the cases themselves, grouped by scenario
|
||||
//! - [`coverage`] — which CLAUDE.md scenario each case claims, and the check that
|
||||
//! 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
|
||||
//! library's unit-test build, not library API. They run under `cargo test`.
|
||||
|
||||
pub mod coverage;
|
||||
pub mod golden;
|
||||
pub mod tables;
|
||||
|
||||
#[cfg(test)]
|
||||
mod purity;
|
||||
#[cfg(test)]
|
||||
mod run;
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user