diff --git a/Cargo.lock b/Cargo.lock index d302ddb..fbd37c3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 5ca4810..34021f1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ resolver = "2" members = [ "core", + "storage", "tests", "desktop/daemon", "desktop/ui", diff --git a/desktop/daemon/Cargo.toml b/desktop/daemon/Cargo.toml index 3dac2a3..fad918a 100644 --- a/desktop/daemon/Cargo.toml +++ b/desktop/daemon/Cargo.toml @@ -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" } \ No newline at end of file +blocker-core = { path = "../../core" } +blocker-storage = { path = "../../storage" } \ No newline at end of file diff --git a/desktop/daemon/src/main.rs b/desktop/daemon/src/main.rs index 69dfdd0..0ffad93 100644 --- a/desktop/daemon/src/main.rs +++ b/desktop/daemon/src/main.rs @@ -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 } diff --git a/storage/Cargo.toml b/storage/Cargo.toml new file mode 100644 index 0000000..8097351 --- /dev/null +++ b/storage/Cargo.toml @@ -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" \ No newline at end of file diff --git a/storage/src/codec.rs b/storage/src/codec.rs new file mode 100644 index 0000000..ab88965 --- /dev/null +++ b/storage/src/codec.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 { + 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 { + 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 { + 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, 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, + pub start_min: Option, + pub end_min: Option, + pub limit_value: Option, + pub window: Option, + pub ssid: Option, + pub latitude: Option, + pub longitude: Option, + pub radius_m: Option, + pub outside: Option, +} + +pub fn encode_conditions(conditions: &[Condition]) -> Vec { + 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::>().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, 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 { + match row.kind.as_str() { + "Time" => { + let days = row.days.as_deref().ok_or(DecodeError::BadDays("".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) -> Result { + 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) -> Result { + let v = value.ok_or(DecodeError::NegativeCount(-1))?; + u32::try_from(v).map_err(|_| DecodeError::NegativeCount(v)) +} + +fn decode_flag(value: i64) -> Result { + 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 { + 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, DecodeError> { + if raw.is_empty() { + return Ok(Vec::new()); + } + raw.split(',') + .map(|part| { + let trimmed = part.trim(); + trimmed + .parse::() + .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 { + 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 { + 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; diff --git a/storage/src/codec/tests.rs b/storage/src/codec/tests.rs new file mode 100644 index 0000000..170acd8 --- /dev/null +++ b/storage/src/codec/tests.rs @@ -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); +} diff --git a/storage/src/lib.rs b/storage/src/lib.rs new file mode 100644 index 0000000..b8e910a --- /dev/null +++ b/storage/src/lib.rs @@ -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 { + 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 { + 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, 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>(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, 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, 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, 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 = 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 { + 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 { + 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 { + // 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, + /// Launch counts per target. + pub launches: std::collections::BTreeMap, +} + +/// 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; diff --git a/storage/src/tests.rs b/storage/src/tests.rs new file mode 100644 index 0000000..d09dbc2 --- /dev/null +++ b/storage/src/tests.rs @@ -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); +}