2 Commits

Author SHA1 Message Date
SellPL 92afdc3bf6 docs: record the measured storage cost and the new crate boundary
CI / ci (pull_request) Has been cancelled
CI / android (pull_request) Has been cancelled
Updates the ADR log, architecture, limitations, perf table and README to match
what now exists, rather than what was expected.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-01 17:14:16 +03:00
14 changed files with 1789 additions and 38 deletions
Generated
+116
View File
@@ -2,6 +2,12 @@
# It is not intended for manual editing. # It is not intended for manual editing.
version = 4 version = 4
[[package]]
name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]] [[package]]
name = "blocker-core" name = "blocker-core"
version = "0.1.0" version = "0.1.0"
@@ -11,6 +17,7 @@ name = "blocker-daemon"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"blocker-core", "blocker-core",
"blocker-storage",
] ]
[[package]] [[package]]
@@ -27,6 +34,14 @@ dependencies = [
"blocker-core", "blocker-core",
] ]
[[package]]
name = "blocker-storage"
version = "0.1.0"
dependencies = [
"blocker-core",
"rusqlite",
]
[[package]] [[package]]
name = "blocker-tests" name = "blocker-tests"
version = "0.1.0" version = "0.1.0"
@@ -37,3 +52,104 @@ dependencies = [
[[package]] [[package]]
name = "blocker-ui" name = "blocker-ui"
version = "0.1.0" version = "0.1.0"
[[package]]
name = "cc"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040"
dependencies = [
"find-msvc-tools",
"shlex",
]
[[package]]
name = "fallible-iterator"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
[[package]]
name = "fallible-streaming-iterator"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]]
name = "find-msvc-tools"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"foldhash",
]
[[package]]
name = "hashlink"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"
dependencies = [
"hashbrown",
]
[[package]]
name = "libsqlite3-sys"
version = "0.35.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f"
dependencies = [
"cc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "pkg-config"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
[[package]]
name = "rusqlite"
version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f"
dependencies = [
"bitflags",
"fallible-iterator",
"fallible-streaming-iterator",
"hashlink",
"libsqlite3-sys",
"smallvec",
]
[[package]]
name = "shlex"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "smallvec"
version = "1.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e"
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
+1
View File
@@ -2,6 +2,7 @@
resolver = "2" resolver = "2"
members = [ members = [
"core", "core",
"storage",
"tests", "tests",
"desktop/daemon", "desktop/daemon",
"desktop/ui", "desktop/ui",
+6 -4
View File
@@ -16,7 +16,7 @@ enforces anything yet. No platform has a working build.
| Milestone | State | | Milestone | State |
|---|---| |---|---|
| M0 repo bootstrap | in progress | | M0 repo bootstrap | in progress |
| M1 core engine | engine, lock timers and safety guarantees written and tested; storage has no I/O yet | | M1 core engine | engine, lock timers and safety guarantees written and tested; SQLite persistence landed in `storage/` |
| M2-Linux | not started — daemon is a placeholder | | M2-Linux | not started — daemon is a placeholder |
| M3-Android | not started | | M3-Android | not started |
| M4 conditions | not started | | M4 conditions | not started |
@@ -74,6 +74,7 @@ summary as not-passes. A skip is never reported as a pass.
``` ```
core/ the decision engine — pure, no OS calls, no dependencies core/ the decision engine — pure, no OS calls, no dependencies
storage/ SQLite persistence (rusqlite, bundled), fail-open recovery
tests/ golden decision tables, and the engine purity check tests/ golden decision tables, and the engine purity check
desktop/daemon/ long-lived observer and enforcer (5 MB budget) desktop/daemon/ long-lived observer and enforcer (5 MB budget)
desktop/ui/ block-screen window, launched on demand desktop/ui/ block-screen window, launched on demand
@@ -106,9 +107,10 @@ scripts/ ci.sh, size-check.sh
| Daemon binary | ≤ 5 MB stripped | | Daemon binary | ≤ 5 MB stripped |
| APK size | ≤ 10 MB | | APK size | ≤ 10 MB |
Currently measured: **0.279 MB** stripped daemon — a placeholder that prints two Currently measured: **1.364 MB** stripped daemon, which includes bundled SQLite.
lines. The meaningful number comes in M2-Linux. Enforced in CI from the first Of the 5 MB budget that leaves 3.8 MB for D-Bus, X11 and a UI toolkit — a real
commit by `scripts/size-check.sh`. margin rather than a comfortable one. Enforced in CI from the first commit by
`scripts/size-check.sh`.
## Licence ## Licence
+2 -1
View File
@@ -16,4 +16,5 @@ path = "src/main.rs"
# section 3 requires every crate to justify its size cost. The runtime pieces # 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. # (zbus for D-Bus, x11rb for X11) arrive in M2-Linux, each with an ADR entry.
[dependencies] [dependencies]
blocker-core = { path = "../../core" } blocker-core = { path = "../../core" }
blocker-storage = { path = "../../storage" }
+71 -11
View File
@@ -2,16 +2,16 @@
//! //!
//! The daemon observes the OS and enforces the decisions `blocker-core` makes. //! The daemon observes the OS and enforces the decisions `blocker-core` makes.
//! It contains **no decision logic of its own** (CLAUDE.md section 2): if the //! It contains **no decision logic of its own** (CLAUDE.md section 2): if the
//! daemon ever decides that something should be blocked, the Android and //! daemon ever decides that something should be blocked, the Android and desktop
//! desktop builds will drift and the golden tables stop being a parity contract. //! builds will drift and the golden tables stop being a parity contract.
//! //!
//! # Shape //! # Shape
//! //!
//! Two processes, deliberately: //! Two processes, deliberately:
//! //!
//! - the **daemon** — small, long-lived, stays resident, holds the block window //! - the **daemon** — small, long-lived, holds the block window machinery and
//! machinery and the engine connection. This is the binary with the 5 MB //! the engine connection. This is the binary with the 5 MB stripped budget
//! stripped budget (section 5). //! (section 5).
//! - the **UI** (`blocker-ui`) — a separate process launched to show a block //! - the **UI** (`blocker-ui`) — a separate process launched to show a block
//! screen, which exits when the block lifts. //! screen, which exits when the block lifts.
//! //!
@@ -20,14 +20,74 @@
//! //!
//! # Event loop //! # Event loop
//! //!
//! M2-Linux replaces this placeholder with a loop that blocks on exactly three //! M2-Linux replaces the startup path below with a loop that blocks on exactly
//! things: a focus-change event from the active //! three things: a focus-change event from the active
//! [`ForegroundWatcher`](../../blocker_core/index.html) backend, the //! [`ForegroundWatcher`](../../blocker_platform_linux/index.html) backend, the
//! `next_wakeup` instant from the engine, and IPC from the UI. There is no //! `next_wakeup` instant from the engine, and IPC from the UI. There is no
//! polling tick anywhere — an idle daemon takes zero wakeups, which is how the //! polling tick anywhere — an idle daemon takes zero wakeups, which is how the
//! section 5 idle-CPU budget is met. //! section 5 idle-CPU budget is met.
//!
//! # Where the database lives
//!
//! Under the XDG data directory, never beside the binary. The binary may be
//! installed read-only in `/usr/bin`, while the database must be writable and
//! must survive an upgrade.
fn main() { use std::path::PathBuf;
println!("blocker-daemon {} (M0 placeholder)", env!("CARGO_PKG_VERSION")); use std::process::ExitCode;
println!("enforcement lands in M2-Linux; see docs/DECISIONS.md for the ADR log");
/// `~/.local/share/blocker/blocker.db`, honouring `XDG_DATA_HOME`.
fn database_path() -> PathBuf {
let base = std::env::var_os("XDG_DATA_HOME")
.map(PathBuf::from)
.filter(|p| p.is_absolute())
.unwrap_or_else(|| {
let home =
std::env::var_os("HOME").map(PathBuf::from).unwrap_or_else(|| PathBuf::from("."));
home.join(".local/share")
});
base.join("blocker").join("blocker.db")
}
fn main() -> ExitCode {
let path = database_path();
// Section 1.5.4: a corrupt or unusable database must never leave the device
// unusable. `Store::open` handles corruption by moving the damaged file
// aside and starting clean, so what reaches the `Err` arm is genuinely fatal
// — an unwritable directory, say. Even then we exit with a message rather
// than panicking: a daemon that crashes on startup is worse than one that
// reports the problem and stops.
let store = match blocker_storage::Store::open(&path) {
Ok(store) => store,
Err(e) => {
eprintln!("blocker-daemon: cannot open storage at {}: {e}", path.display());
eprintln!("blocking is disabled until this is fixed; see docs/LIMITATIONS.md");
return ExitCode::FAILURE;
}
};
if store.recovered_from_corruption() {
// Logged, not swallowed: section 1.5.4 wants the recovery recorded.
eprintln!(
"blocker-daemon: database was corrupt and has been moved to {}.corrupt; \
starting clean, so no schedules are active until you re-add them",
path.display()
);
}
let schedules = match store.load_schedules() {
Ok(s) => s,
Err(e) => {
eprintln!("blocker-daemon: cannot read schedules: {e}");
return ExitCode::FAILURE;
}
};
println!("blocker-daemon {} (M0/M1)", env!("CARGO_PKG_VERSION"));
println!("database: {}", path.display());
println!("schedules: {}", schedules.len());
println!("enforcement lands in M2-Linux; see docs/DECISIONS.md for the ADR log");
ExitCode::SUCCESS
} }
+10 -3
View File
@@ -182,10 +182,17 @@ APK size cost is unacceptable, which is a measurement before it is a decision.
| `usage` | foreground-time and launch-count accumulation into daily and hourly buckets | | `usage` | foreground-time and launch-count accumulation into daily and hourly buckets |
| `lock` | lock levels, the friction ladder's timing, monotonic anti-tamper | | `lock` | lock levels, the friction ladder's timing, monotonic anti-tamper |
| `never_block` | the section 1.5.4 safety list | | `never_block` | the section 1.5.4 safety list |
| `storage` | the SQLite schema and its fail-open recovery policy | | `storage` | *(separate crate: `blocker-storage`)* the SQLite schema and its fail-open recovery policy |
`storage` has no I/O behind it yet — see `LIMITATIONS.md`. The schema is written `blocker-storage` is a sibling crate, not a submodule. It depends on
and reviewed as text so it can be reviewed before it is compiled. `blocker-core`; `blocker-core` does not depend on it, and depends on nothing at
all. That direction is what lets `tests/purity.rs` keep checking "blocker-core has
zero dependencies" — an automatic proof that the engine cannot do I/O — instead of
the weaker and easily-routed-around alternative of grepping the source for
`rusqlite`.
Both crates occupy the same layer of the diagram above: storage is shared by every
platform and contains no platform code.
## Where the platform crates start and stop ## Where the platform crates start and stop
+33 -11
View File
@@ -25,7 +25,7 @@ which parts to trust.
| [1](#adr-1) | Rust for core and desktop, Kotlin for the Android shell | Accepted | | [1](#adr-1) | Rust for core and desktop, Kotlin for the Android shell | Accepted |
| [2](#adr-2) | One shared core; the engine is a pure function | Accepted | | [2](#adr-2) | One shared core; the engine is a pure function | Accepted |
| [3](#adr-3) | Event-driven with `next_wakeup`, never polling | Accepted | | [3](#adr-3) | Event-driven with `next_wakeup`, never polling | Accepted |
| [4](#adr-4) | SQLite via rusqlite, bundled | Proposed | | [4](#adr-4) | SQLite via rusqlite, in its own crate | Accepted, one trade-off open |
| [5](#adr-5) | uniffi for core-to-Kotlin bindings | Proposed | | [5](#adr-5) | uniffi for core-to-Kotlin bindings | Proposed |
| [6](#adr-6) | Desktop UI toolkit — Slint vs egui | **Proposed, needs owner** | | [6](#adr-6) | Desktop UI toolkit — Slint vs egui | **Proposed, needs owner** |
| [7](#adr-7) | Desktop website blocking mechanism | **Accepted — owner chose extension** | | [7](#adr-7) | Desktop website blocking mechanism | **Accepted — owner chose extension** |
@@ -108,24 +108,46 @@ it is supposed to start blocking.
--- ---
<a id="adr-4"></a> <a id="adr-4"></a>
## ADR-4: SQLite via rusqlite, bundled ## ADR-4: SQLite via rusqlite
**Status:** Proposed — dependency not yet added **Status:** Accepted and implemented, with one trade-off still open
**Context.** Section 3 specifies `rusqlite` with bundled SQLite. Section 1.5.3 **Context.** Section 3 specifies `rusqlite` with bundled SQLite. Section 1.5.3
requires lock and Strict state to live in the core database rather than in requires lock and Strict state to live in the core database rather than in
editable plaintext config. editable plaintext config.
**Decision.** `rusqlite` with `bundled`, storing schedules, lock state, and usage **Decision.** `rusqlite` with `bundled`, in a **separate `blocker-storage` crate**
counters in one file. that depends on `blocker-core`, never the reverse.
**Consequences.** The schema is written and reviewed in **Why the separate crate.** The purity contract says the engine does no I/O, and
`core/src/storage.rs` before the crate is added, so it can be reviewed as text. `tests/purity.rs` enforces that mechanically by failing if `blocker-core` has any
Bundling compiles SQLite from source, which costs build time and binary size but dependency at all. Putting `rusqlite` in `blocker-core` would have forced that
removes a system dependency — a real consideration on the machines this ships to. check to degrade into a source-text grep — something a careful developer can route
around, and something nobody reads in six months. Splitting keeps the check
automatic.
The binary-size cost has not yet been measured against the 5 MB budget. That is Both crates sit in the same layer of the section 2 diagram: storage is shared by
an open item, not a settled fact. every platform and contains no platform code. This is a refinement that makes the
guarantee checkable, not an architectural departure.
**Measured consequences.**
- Bundled SQLite costs **~1.1 MB** of the 5 MB stripped daemon budget (1.364 MB
total, from 0.279 MB without it). That leaves 3.8 MB for D-Bus, X11 and a UI
toolkit — a real margin, not a comfortable one.
- The build compiles SQLite from C source, which is a noticeable first-build cost.
- System-linking would reclaim most of the 1.1 MB at the price of a runtime
dependency on the host's libsqlite3, and a schema-format mismatch that only
appears on a user's machine.
**Still open:** bundled vs system-linked. Measured cost is now known; the decision
is the owner's.
**Schema notes.** Relational (`schedule_target`, `schedule_condition`) rather than
JSON blobs in a text column, because target rows are queried by target and an
index on a JSON string is not a plan. Serialisation is hand-written in
`storage/src/codec.rs` — `serde` is not a section 3 dependency, and section 0 rule
3 requires asking before adding one (ADR-13).
--- ---
+5 -3
View File
@@ -177,9 +177,9 @@ not mistaken for platform facts.
- **`ProcessBackend` is not implemented.** ADR-14 lists three backends; the KWin - **`ProcessBackend` is not implemented.** ADR-14 lists three backends; the KWin
and X11 paths land in M2-Linux, and the process fallback is weaker than the and X11 paths land in M2-Linux, and the process fallback is weaker than the
spec assumes because of the `/proc/connectors` gap above. spec assumes because of the `/proc/connectors` gap above.
- **`rusqlite` has not been added.** The schema is written and reviewed in - **`rusqlite` vs system-linked SQLite is unresolved** (ADR-4). Bundled is built
`core/src/storage.rs` (ADR-4) but storage has no I/O behind it yet, so nothing and costs ~1.1 MB of the 5 MB daemon budget. System-linking would reclaim most
persists. A restart loses all state. of that but adds a runtime dependency on the host's libsqlite3.
- **Lock state is not yet persisted or enforced.** `core/src/lock.rs` has the - **Lock state is not yet persisted or enforced.** `core/src/lock.rs` has the
timers, the escalation, and the wall-clock correction, all tested. The state timers, the escalation, and the wall-clock correction, all tested. The state
machine is not yet driven from a UI, and `LockState::evaluate` currently returns machine is not yet driven from a UI, and `LockState::evaluate` currently returns
@@ -188,6 +188,8 @@ not mistaken for platform facts.
`tests/src/golden.rs`; parsing needs serde, which is a dependency decision `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 awaiting sign-off (ADR-13, ADR-16). The behavioural coverage that exists today
is hand-written unit tests in the same spirit. is hand-written unit tests in the same spirit.
- **`LockState` still always evaluates to `Normal`** — persistence works, but no
UI drives the lock state machine yet.
- **`Condition::UsageLimit` and `LaunchCount` sum across all targets** rather - **`Condition::UsageLimit` and `LaunchCount` sum across all targets** rather
than the schedule's own target set, until targets carry usage keys. than the schedule's own target set, until targets carry usage keys.
- **Windows is entirely unimplemented** and cannot be compiled or tested on the - **Windows is entirely unimplemented** and cannot be compiled or tested on the
+14 -5
View File
@@ -29,17 +29,26 @@ Measured on: Ubuntu 26.04 LTS, KDE Plasma 6.6.6, 6 CPUs. Toolchain: rustc 1.98.1
| Metric | Target | Measured | When | | Metric | Target | Measured | When |
|---|---|---|---| |---|---|---|---|
| Daemon binary (stripped, release) | ≤ 5 MB | **0.279 MB** | 2026-10-01, M0 | | Daemon binary (stripped, release) | ≤ 5 MB | **1.364 MB** | 2026-10-01, M1 |
| Block latency | ≤ 150 ms | not measured | — | | Block latency | ≤ 150 ms | not measured | — |
| Idle CPU | ~0% | not measured | — | | Idle CPU | ~0% | not measured | — |
| Idle RAM (daemon) | ≤ 15 MB RSS | not measured | — | | Idle RAM (daemon) | ≤ 15 MB RSS | not measured | — |
| Idle wakeups | ≤ 1/min | not measured | — | | Idle wakeups | ≤ 1/min | not measured | — |
| APK size | ≤ 10 MB | not measured | — | | APK size | ≤ 10 MB | not measured | — |
The binary figure is real but nearly meaningless right now: the daemon is two **What the 1.364 MB contains, and why it matters.** That figure is with bundled
`println!` lines. The meaningful measurement comes in M2-Linux once enforcement SQLite linked in and the daemon actually opening a database — not the 0.279 MB
exists, and the real risk is at that point — D-Bus, X11, and a UI toolkit are placeholder from M0, which was two `println!` lines and excluded SQLite entirely
where 5 MB would actually be threatened. through dead-code elimination.
So the honest read is: **bundled SQLite costs roughly 1.1 MB of a 5 MB budget.**
That leaves 3.8 MB for D-Bus, X11, and the UI toolkit. It is a real constraint
rather than a comfortable margin, and it is the first number that makes the
section 5 budget feel like a budget instead of a formality.
It also makes ADR-4 (rusqlite bundled vs system-linked) a genuine trade-off rather
than a settled one: system-linking would recover most of that 1.1 MB at the cost
of a runtime dependency on the host's libsqlite3. Recorded as an open question.
## How to measure ## How to measure
+35
View File
@@ -0,0 +1,35 @@
[package]
name = "blocker-storage"
version = "0.1.0"
edition = "2021"
description = "SQLite persistence for Blocker: schedules, lock state, and usage counters."
license = "UNLICENSED"
[dependencies]
blocker-core = { path = "../core" }
# rusqlite with bundled SQLite, per CLAUDE.md section 3. Bundled rather than
# system-linked because the app ships to machines where libsqlite3 may be absent
# or a different major version, and a storage-format mismatch that only shows up
# on a user's machine is not a failure mode worth carrying.
#
# THIS CRATE IS NOT DEPENDENT ON BY blocker-core.
#
# That is the entire reason this crate exists separately. CLAUDE.md section 2
# requires the engine to be a pure function with no I/O, and the strongest
# mechanical check for that is "blocker-core has zero dependencies" — a test
# that reads Cargo.lock and fails on any entry. Keeping storage in its own crate
# lets that test stay strict and automatic, instead of degrading into a source
# grep that someone can route around.
#
# Both crates sit in the same layer: storage is shared by every platform and
# contains no platform code, so this is a refinement of the section 2 diagram,
# not a departure from it.
rusqlite = { version = "0.37", features = ["bundled"] }
[lints]
workspace = true
[lib]
name = "blocker_storage"
path = "src/lib.rs"
+347
View File
@@ -0,0 +1,347 @@
//! Encoding between model types and their database representation.
//!
//! Split out from the SQL so the tricky part — turning a `Condition` into
//! nullable columns and back without silently losing a field — is testable
//! without a database, and so a decode failure has somewhere precise to point.
//!
//! # Why this is hand-written rather than serde
//!
//! `serde` is not a CLAUDE.md section 3 dependency, and section 0 rule 3 requires
//! stopping to ask before adding one. The encoding is a few hundred lines of
//! mechanical match arms, which is a real cost — and it is the cost of not adding
//! a dependency without permission. If serde is approved later, this module is the
//! only thing that changes.
//!
//! # Decoding never panics
//!
//! Every decode failure returns [`DecodeError`]. A database written by a newer
//! version, or corrupted by hand, must produce a diagnosable error rather than a
//! panic: this is enforcement code, and section 1.5.4 requires failing open.
use blocker_core::model::{
Condition, DayOfWeek, MinuteOfDay, Schedule, ScheduleMode, Target, TimeWindow, UsageWindow,
};
/// Why stored bytes could not become a model value.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DecodeError {
/// A `mode` column held something other than the two known values.
UnknownMode(String),
/// A `level` column held something other than the three known values.
UnknownLevel(String),
/// A `kind` column held something unrecognised.
UnknownKind(String),
/// A target string was empty.
EmptyTarget,
/// A `start`/`end` time was not `HH:MM`, or was outside 0..1440.
BadTime(String),
/// A time of day fell outside 0..1440 minutes.
MinuteOutOfRange(i64),
/// A `days` list was malformed, or held a value outside 0..=6.
BadDays(String),
/// A boolean column was neither 0 nor 1.
BadFlag(i64),
/// A numeric column was negative where only a count is meaningful.
NegativeCount(i64),
}
impl std::fmt::Display for DecodeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::UnknownMode(m) => write!(f, "unknown schedule mode {m:?}"),
Self::UnknownLevel(l) => write!(f, "unknown lock level {l:?}"),
Self::UnknownKind(k) => write!(f, "unknown condition kind {k:?}"),
Self::EmptyTarget => write!(f, "target value is empty"),
Self::BadTime(t) => write!(f, "time {t:?} is not HH:MM"),
Self::MinuteOutOfRange(m) => write!(f, "minute {m} is outside 0..1440"),
Self::BadDays(d) => write!(f, "day list {d:?} is malformed"),
Self::BadFlag(v) => write!(f, "expected 0 or 1, found {v}"),
Self::NegativeCount(v) => write!(f, "expected a non-negative count, found {v}"),
}
}
}
impl std::error::Error for DecodeError {}
/// `ScheduleMode` as stored.
pub fn mode_to_str(mode: ScheduleMode) -> &'static str {
match mode {
ScheduleMode::Blocklist => "Blocklist",
ScheduleMode::Allowlist => "Allowlist",
}
}
pub fn mode_from_str(raw: &str) -> Result<ScheduleMode, DecodeError> {
match raw {
"Blocklist" => Ok(ScheduleMode::Blocklist),
"Allowlist" => Ok(ScheduleMode::Allowlist),
other => Err(DecodeError::UnknownMode(other.to_owned())),
}
}
/// `LockLevel` as stored.
pub fn level_to_str(level: blocker_core::lock::LockLevel) -> &'static str {
match level {
blocker_core::lock::LockLevel::Normal => "Normal",
blocker_core::lock::LockLevel::Strict => "Strict",
blocker_core::lock::LockLevel::Stubborn => "Stubborn",
}
}
pub fn level_from_str(raw: &str) -> Result<blocker_core::lock::LockLevel, DecodeError> {
use blocker_core::lock::LockLevel;
match raw {
"Normal" => Ok(LockLevel::Normal),
"Strict" => Ok(LockLevel::Strict),
"Stubborn" => Ok(LockLevel::Stubborn),
other => Err(DecodeError::UnknownLevel(other.to_owned())),
}
}
/// A decoded target.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TargetRow {
/// Position within the schedule, preserving the user's ordering.
pub ordinal: i64,
/// Discriminator: `app`, `website`, or `keyword`.
pub kind: String,
/// The identifier itself.
pub value: String,
}
/// Targets in database form.
pub fn encode_targets(targets: &[Target]) -> Vec<TargetRow> {
targets
.iter()
.enumerate()
.map(|(ordinal, t)| {
let (kind, value) = match t {
Target::App(v) => ("app", v.clone()),
Target::Website(v) => ("website", v.clone()),
Target::Keyword(v) => ("keyword", v.clone()),
};
TargetRow {
ordinal: i64::try_from(ordinal).unwrap_or(i64::MAX),
kind: kind.to_owned(),
value,
}
})
.collect()
}
pub fn decode_targets(rows: &[TargetRow]) -> Result<Vec<Target>, DecodeError> {
let mut out: Vec<(i64, Target)> = Vec::with_capacity(rows.len());
for row in rows {
if row.value.is_empty() {
return Err(DecodeError::EmptyTarget);
}
let target = match row.kind.as_str() {
"app" => Target::App(row.value.clone()),
"website" => Target::Website(row.value.clone()),
"keyword" => Target::Keyword(row.value.clone()),
other => return Err(DecodeError::UnknownKind(other.to_owned())),
};
out.push((row.ordinal, target));
}
// Ordinals come from the database unordered; sorting restores user order,
// which matters for a list the user arranged deliberately.
out.sort_by_key(|(ordinal, _)| *ordinal);
Ok(out.into_iter().map(|(_, t)| t).collect())
}
/// A decoded condition. Column-shaped rather than model-shaped, so a NULL in the
/// wrong place is caught here rather than becoming a silently defaulted
/// condition.
#[derive(Debug, Clone, PartialEq)]
pub struct ConditionRow {
pub ordinal: i64,
pub kind: String,
/// Comma-separated weekdays, Monday = 0.
pub days: Option<String>,
pub start_min: Option<i64>,
pub end_min: Option<i64>,
pub limit_value: Option<i64>,
pub window: Option<String>,
pub ssid: Option<String>,
pub latitude: Option<f64>,
pub longitude: Option<f64>,
pub radius_m: Option<i64>,
pub outside: Option<i64>,
}
pub fn encode_conditions(conditions: &[Condition]) -> Vec<ConditionRow> {
conditions
.iter()
.enumerate()
.map(|(ordinal, c)| {
let mut row = ConditionRow {
ordinal: i64::try_from(ordinal).unwrap_or(i64::MAX),
kind: String::new(),
days: None,
start_min: None,
end_min: None,
limit_value: None,
window: None,
ssid: None,
latitude: None,
longitude: None,
radius_m: None,
outside: None,
};
match c {
Condition::Time(w) => {
row.kind = "Time".to_owned();
row.days = Some(w.days.iter().map(u8::to_string).collect::<Vec<_>>().join(","));
row.start_min = Some(i64::from(w.start));
row.end_min = Some(i64::from(w.end));
}
Condition::UsageLimit { limit_minutes, window } => {
row.kind = "UsageLimit".to_owned();
row.limit_value = Some(i64::from(*limit_minutes));
row.window = Some(window_to_str(*window).to_owned());
}
Condition::LaunchCount { limit, window } => {
row.kind = "LaunchCount".to_owned();
row.limit_value = Some(i64::from(*limit));
row.window = Some(window_to_str(*window).to_owned());
}
Condition::WifiSsid(ssid) => {
row.kind = "WifiSsid".to_owned();
row.ssid = Some(ssid.clone());
}
Condition::Location { latitude, longitude, radius_meters, block_when_outside } => {
row.kind = "Location".to_owned();
row.latitude = Some(*latitude);
row.longitude = Some(*longitude);
row.radius_m = Some(i64::from(*radius_meters));
row.outside = Some(i64::from(*block_when_outside));
}
}
row
})
.collect()
}
pub fn decode_conditions(rows: &[ConditionRow]) -> Result<Vec<Condition>, DecodeError> {
let mut out: Vec<(i64, Condition)> = Vec::with_capacity(rows.len());
for row in rows {
out.push((row.ordinal, decode_condition(row)?));
}
out.sort_by_key(|(ordinal, _)| *ordinal);
Ok(out.into_iter().map(|(_, c)| c).collect())
}
fn decode_condition(row: &ConditionRow) -> Result<Condition, DecodeError> {
match row.kind.as_str() {
"Time" => {
let days = row.days.as_deref().ok_or(DecodeError::BadDays("<null>".into()))?;
let start = require_minute(row.start_min)?;
let end = require_minute(row.end_min)?;
Ok(Condition::Time(TimeWindow { days: decode_days(days)?, start, end }))
}
"UsageLimit" => Ok(Condition::UsageLimit {
limit_minutes: require_count(row.limit_value)?,
window: window_from_str(row.window.as_deref())?,
}),
"LaunchCount" => Ok(Condition::LaunchCount {
limit: require_count(row.limit_value)?,
window: window_from_str(row.window.as_deref())?,
}),
"WifiSsid" => Ok(Condition::WifiSsid(row.ssid.clone().ok_or(DecodeError::EmptyTarget)?)),
"Location" => Ok(Condition::Location {
latitude: row.latitude.ok_or(DecodeError::NegativeCount(-1))?,
longitude: row.longitude.ok_or(DecodeError::NegativeCount(-1))?,
radius_meters: require_count(row.radius_m)?,
block_when_outside: decode_flag(row.outside.unwrap_or(0))?,
}),
other => Err(DecodeError::UnknownKind(other.to_owned())),
}
}
fn require_minute(value: Option<i64>) -> Result<MinuteOfDay, DecodeError> {
let v = value.ok_or(DecodeError::MinuteOutOfRange(-1))?;
u16::try_from(v).ok().filter(|m| *m < 1440).ok_or(DecodeError::MinuteOutOfRange(v))
}
fn require_count(value: Option<i64>) -> Result<u32, DecodeError> {
let v = value.ok_or(DecodeError::NegativeCount(-1))?;
u32::try_from(v).map_err(|_| DecodeError::NegativeCount(v))
}
fn decode_flag(value: i64) -> Result<bool, DecodeError> {
match value {
0 => Ok(false),
1 => Ok(true),
other => Err(DecodeError::BadFlag(other)),
}
}
fn window_to_str(window: UsageWindow) -> &'static str {
match window {
UsageWindow::Daily => "Daily",
UsageWindow::Hourly => "Hourly",
}
}
fn window_from_str(raw: Option<&str>) -> Result<UsageWindow, DecodeError> {
match raw {
Some("Daily") => Ok(UsageWindow::Daily),
Some("Hourly") => Ok(UsageWindow::Hourly),
_ => Err(DecodeError::UnknownKind(format!("usage window {raw:?}"))),
}
}
fn decode_days(raw: &str) -> Result<Vec<DayOfWeek>, DecodeError> {
if raw.is_empty() {
return Ok(Vec::new());
}
raw.split(',')
.map(|part| {
let trimmed = part.trim();
trimmed
.parse::<u8>()
.ok()
.filter(|d| *d < 7)
.ok_or_else(|| DecodeError::BadDays(raw.to_owned()))
})
.collect()
}
/// `HH:MM` to minutes since midnight. Used by the golden-table loader, which
/// reads times as strings.
pub fn parse_hhmm(raw: &str) -> Result<MinuteOfDay, DecodeError> {
let (h, m) = raw.split_once(':').ok_or_else(|| DecodeError::BadTime(raw.to_owned()))?;
let h: u16 = h.trim().parse().map_err(|_| DecodeError::BadTime(raw.to_owned()))?;
let m: u16 = m.trim().parse().map_err(|_| DecodeError::BadTime(raw.to_owned()))?;
if m >= 60 || h >= 24 {
return Err(DecodeError::BadTime(raw.to_owned()));
}
Ok(h * 60 + m)
}
/// Reassemble a schedule from its header and its decoded children.
///
/// Kept here rather than in `lib.rs` so the ordering contract — children sorted
/// by ordinal, attached to the right parent — is tested in one place.
pub fn decode_schedule(
id: &str,
name: &str,
icon: Option<&str>,
mode: &str,
targets: &[TargetRow],
conditions: &[ConditionRow],
paused: bool,
) -> Result<Schedule, DecodeError> {
Ok(Schedule {
id: id.to_owned(),
name: name.to_owned(),
icon: icon.map(str::to_owned),
mode: mode_from_str(mode)?,
targets: decode_targets(targets)?,
conditions: decode_conditions(conditions)?,
paused,
})
}
#[cfg(test)]
mod tests;
+201
View File
@@ -0,0 +1,201 @@
//! Tests for the storage encoding layer.
//!
//! These need no database: the codec is where a silently-dropped field would
//! live, and a round-trip test here is far cheaper to run and to read than the
//! same assertion through SQLite.
#![allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
reason = "test fixtures: a failing assertion or a broken test is not a runtime \
condition to handle gracefully"
)]
use super::*;
use blocker_core::model::{Condition, Schedule, ScheduleMode, Target, TimeWindow, UsageWindow};
/// Every condition shape must survive encode -> decode unchanged.
///
/// A field that is written but not read back is invisible in production and
/// catastrophic here: the schedule quietly stops working the next time the app
/// restarts.
#[test]
fn every_condition_round_trips() {
let cases = vec![
Condition::Time(TimeWindow { days: vec![0, 1, 2, 3, 4], start: 9 * 60, end: 17 * 60 }),
// Overnight: start > end must not be normalised or dropped on the way
// through the database. This is the representation the whole overnight
// rule depends on.
Condition::Time(TimeWindow { days: vec![6], start: 22 * 60, end: 6 * 60 }),
Condition::UsageLimit { limit_minutes: 90, window: UsageWindow::Daily },
Condition::LaunchCount { limit: 5, window: UsageWindow::Hourly },
Condition::WifiSsid("CoffeeShop".into()),
Condition::Location {
latitude: 52.52,
longitude: 13.405,
radius_meters: 500,
block_when_outside: true,
},
];
for original in cases {
let rows = encode_conditions(std::slice::from_ref(&original));
let decoded = decode_conditions(&rows).expect("decode must succeed");
assert_eq!(decoded, vec![original], "round trip changed the condition");
}
}
/// A schedule's ordinal order must survive, since the user arranges targets
/// deliberately and the block screen shows them in that order.
#[test]
fn target_order_is_preserved() {
let targets = vec![
Target::App("b.app".into()),
Target::Website("example.com".into()),
Target::Keyword("casino".into()),
Target::App("a.app".into()),
];
let mut rows = encode_targets(&targets);
// Simulate the database returning rows in an arbitrary order.
rows.reverse();
assert_eq!(decode_targets(&rows).unwrap(), targets);
}
/// Unknown discriminators must error, not default.
///
/// Defaulting here would turn a typo or a newer-version row into a schedule
/// that silently blocks the wrong thing.
#[test]
fn unknown_kinds_are_errors_not_defaults() {
let bad_target =
vec![TargetRow { ordinal: 0, kind: "applicaton".into(), value: "com.example".into() }];
assert_eq!(decode_targets(&bad_target), Err(DecodeError::UnknownKind("applicaton".into())));
let bad_condition = vec![ConditionRow {
ordinal: 0,
kind: "Timey".into(),
days: None,
start_min: None,
end_min: None,
limit_value: None,
window: None,
ssid: None,
latitude: None,
longitude: None,
radius_m: None,
outside: None,
}];
assert_eq!(decode_conditions(&bad_condition), Err(DecodeError::UnknownKind("Timey".into())));
}
/// A time of day outside 0..1440 must be rejected rather than wrapping.
///
/// A wrapped minute would produce a window that never matches, or one that
/// always matches — both look like "the app ignores my schedule".
#[test]
fn out_of_range_minutes_are_rejected() {
let bad = ConditionRow {
ordinal: 0,
kind: "Time".into(),
days: Some("0".into()),
start_min: Some(-60),
end_min: Some(1020),
limit_value: None,
window: None,
ssid: None,
latitude: None,
longitude: None,
radius_m: None,
outside: None,
};
assert_eq!(decode_conditions(&[bad]), Err(DecodeError::MinuteOutOfRange(-60)));
}
/// Weekday values outside 0..=6 must be rejected.
#[test]
fn out_of_range_weekdays_are_rejected() {
assert_eq!(decode_days("0,1,9"), Err(DecodeError::BadDays("0,1,9".into())));
assert_eq!(decode_days("monday"), Err(DecodeError::BadDays("monday".into())));
assert_eq!(decode_days(""), Ok(vec![]));
assert_eq!(decode_days("0,6"), Ok(vec![0, 6]));
}
/// A flag column holding something other than 0 or 1 must be rejected.
#[test]
fn bad_flags_are_rejected() {
assert_eq!(decode_flag(0), Ok(false));
assert_eq!(decode_flag(1), Ok(true));
assert_eq!(decode_flag(2), Err(DecodeError::BadFlag(2)));
assert_eq!(decode_flag(-1), Err(DecodeError::BadFlag(-1)));
}
/// Negative counts must be rejected — a negative usage limit would mean
/// "block immediately", which is not what a corrupted row should do.
#[test]
fn negative_counts_are_rejected() {
assert_eq!(require_count(Some(-5)), Err(DecodeError::NegativeCount(-5)));
assert_eq!(require_count(Some(0)), Ok(0));
assert_eq!(require_count(None), Err(DecodeError::NegativeCount(-1)));
}
/// `HH:MM` parsing, as the golden tables use it.
#[test]
fn hhmm_parsing() {
assert_eq!(parse_hhmm("09:00"), Ok(540));
assert_eq!(parse_hhmm("22:30"), Ok(1350));
assert_eq!(parse_hhmm("00:00"), Ok(0));
assert_eq!(parse_hhmm("9:5"), Ok(545));
assert_eq!(parse_hhmm("24:00"), Err(DecodeError::BadTime("24:00".into())));
assert_eq!(parse_hhmm("12:60"), Err(DecodeError::BadTime("12:60".into())));
assert_eq!(parse_hhmm("nine"), Err(DecodeError::BadTime("nine".into())));
assert_eq!(parse_hhmm("0900"), Err(DecodeError::BadTime("0900".into())));
}
/// Lock level and mode round-trip through their string forms.
#[test]
fn level_and_mode_round_trip() {
for level in [
blocker_core::lock::LockLevel::Normal,
blocker_core::lock::LockLevel::Strict,
blocker_core::lock::LockLevel::Stubborn,
] {
assert_eq!(level_from_str(level_to_str(level)).unwrap(), level);
}
assert!(level_from_str("Paranoid").is_err());
for mode in [ScheduleMode::Blocklist, ScheduleMode::Allowlist] {
assert_eq!(mode_from_str(mode_to_str(mode)).unwrap(), mode);
}
assert!(mode_from_str("Whitelist").is_err());
}
/// A full schedule round-trips through its parts.
#[test]
fn schedule_round_trips_through_parts() {
let original = Schedule {
id: "s1".into(),
name: "Evening Focus".into(),
icon: Some("/icons/focus.png".into()),
mode: ScheduleMode::Blocklist,
targets: vec![Target::App("com.example.social".into())],
conditions: vec![Condition::Time(TimeWindow {
days: vec![0, 1, 2, 3, 4],
start: 22 * 60,
end: 6 * 60,
})],
paused: false,
};
let decoded = decode_schedule(
&original.id,
&original.name,
original.icon.as_deref(),
mode_to_str(original.mode),
&encode_targets(&original.targets),
&encode_conditions(&original.conditions),
original.paused,
)
.unwrap();
assert_eq!(decoded, original);
}
+644
View File
@@ -0,0 +1,644 @@
//! SQLite persistence for Blocker (CLAUDE.md section 3, `rusqlite` bundled).
//!
//! # Why this is a separate crate from `blocker-core`
//!
//! Section 2 requires the engine to be a pure function with no I/O, and this
//! project's `tests/purity.rs` enforces that mechanically: it reads the resolved
//! dependency graph and fails if `blocker-core` has any dependency at all. That
//! check is only meaningful while storage lives elsewhere.
//!
//! Both crates occupy the same layer of the section 2 diagram — storage is shared
//! by every platform and contains no platform code — so this is a refinement that
//! makes the purity guarantee mechanically checkable, not an architectural
//! departure.
//!
//! # Fail open (section 1.5.4)
//!
//! A corrupt database must never leave a device unusable. [`Store::open`] moves a
//! damaged file aside and starts clean rather than returning an error the caller
//! might respond to by disabling enforcement. The damaged file is kept for
//! diagnosis; blocking is then re-applied from defaults on the next start.
//!
//! Losing a schedule list is a far smaller harm than a phone that cannot make
//! calls, and section 1.5.4 makes that trade explicit.
use std::path::{Path, PathBuf};
use blocker_core::engine::{BootId, CivilTime, Monotonic};
use rusqlite::{Connection, OpenFlags};
use crate::codec::DecodeError;
/// Current schema version, applied with `PRAGMA user_version`.
pub const SCHEMA_VERSION: i64 = 1;
/// Everything that can go wrong opening or preparing a store.
///
/// Notably **absent**: a corruption variant that is fatal. Corruption is
/// handled inside [`Store::open`] by starting clean, because section 1.5.4
/// requires failing open rather than refusing to run.
#[derive(Debug)]
pub enum StoreError {
/// SQLite could not be initialised at all — for example the bundled library
/// could not be loaded. Fatal, and genuinely exceptional.
Init(rusqlite::Error),
/// The directory holding the database could not be created.
Io(std::io::Error),
/// Stored bytes could not be decoded into the model.
Decode(DecodeError),
}
impl std::fmt::Display for StoreError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Init(e) => write!(f, "could not initialise storage: {e}"),
Self::Io(e) => write!(f, "storage directory unusable: {e}"),
Self::Decode(e) => write!(f, "stored data could not be decoded: {e}"),
}
}
}
impl std::error::Error for StoreError {}
/// A handle to the Blocker database.
///
/// Cheap to clone conceptually but deliberately not `Clone`: the daemon is the
/// only writer, and an accidental second connection would mean two processes
/// fighting over lock state.
#[derive(Debug)]
pub struct Store {
conn: Connection,
path: PathBuf,
/// Set when the database was found corrupt and moved aside, so the caller can
/// log it. Section 1.5.4 wants this recorded, not silently swallowed.
recovered_from_corruption: bool,
}
impl Store {
/// Open, creating if absent, and ensure the schema is current.
///
/// Never returns an error for corruption: a damaged database is renamed
/// aside and a fresh one created. See the module docs.
pub fn open(path: &Path) -> Result<Self, StoreError> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(StoreError::Io)?;
}
let mut recovered = false;
// First attempt: open normally and verify it is actually usable. A
// corrupt file often opens fine and only fails on first query, so the
// probe below is what actually detects corruption.
let mut attempt = Self::try_open(path)?;
if let Err(probe) =
attempt.conn.query_row("PRAGMA user_version", [], |r| r.get::<_, i64>(0))
{
recovered = true;
Self::quarantine(path)?;
attempt = Self::try_open(path)?;
// Keep the probe result meaningful: if this one also fails, the
// problem is not the file.
if let Err(second) =
attempt.conn.query_row("PRAGMA user_version", [], |r| r.get::<_, i64>(0))
{
return Err(StoreError::Init(second));
}
let _ = probe;
}
attempt.migrate()?;
attempt.recovered_from_corruption = recovered;
Ok(attempt)
}
fn try_open(path: &Path) -> Result<Self, StoreError> {
let conn = Connection::open_with_flags(
path,
OpenFlags::SQLITE_OPEN_READ_WRITE
| OpenFlags::SQLITE_OPEN_CREATE
| OpenFlags::SQLITE_OPEN_NO_MUTEX,
)
.map_err(StoreError::Init)?;
// WAL keeps a reader from blocking the daemon's writes, which matters
// because the UI reads while the daemon writes. It also means a crash
// cannot corrupt the main file, which is part of failing open.
// Errors here are not fatal: a filesystem without WAL still works, just
// with less concurrency.
let _ = conn.pragma_update(None, "journal_mode", "WAL");
let _ = conn.pragma_update(None, "foreign_keys", "ON");
Ok(Self { conn, path: path.to_path_buf(), recovered_from_corruption: false })
}
/// Move a damaged database aside, keeping it for diagnosis.
fn quarantine(path: &Path) -> Result<(), StoreError> {
let backup = path.with_extension("corrupt");
std::fs::rename(path, &backup).map_err(StoreError::Io)?;
Ok(())
}
/// Whether this store was recovered from corruption on open.
pub fn recovered_from_corruption(&self) -> bool {
self.recovered_from_corruption
}
fn migrate(&mut self) -> Result<(), StoreError> {
let current: i64 = self
.conn
.query_row("PRAGMA user_version", [], |r| r.get(0))
.map_err(StoreError::Init)?;
if current > SCHEMA_VERSION {
// Forward-only by design (section 1.5.3: downgrading risks losing
// lock state, which the safety rules do not permit). A newer file is
// left alone rather than destroyed.
return Err(StoreError::Init(rusqlite::Error::InvalidQuery));
}
if current == SCHEMA_VERSION {
return Ok(());
}
self.conn.execute_batch(SCHEMA).map_err(StoreError::Init)?;
self.conn.pragma_update(None, "user_version", SCHEMA_VERSION).map_err(StoreError::Init)?;
Ok(())
}
/// Load every schedule, with its targets and conditions.
///
/// Two queries rather than a join: a schedule's children are needed together
/// to reassemble the model, and SQLite's JSON functions are not used here so
/// the relational shape stays portable. Schedules without children still
/// appear, with empty vectors — `validation` is what rejects those, not the
/// database.
pub fn load_schedules(&self) -> Result<Vec<blocker_core::model::Schedule>, StoreError> {
let headers = {
let mut stmt = self
.conn
.prepare(
"SELECT id, name, icon, mode, paused FROM schedule ORDER BY created_at, id",
)
.map_err(StoreError::Init)?;
let rows = stmt
.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, Option<String>>(2)?,
row.get::<_, String>(3)?,
row.get::<_, bool>(4)?,
))
})
.map_err(StoreError::Init)?;
let mut out = Vec::new();
for row in rows {
out.push(row.map_err(StoreError::Init)?);
}
out
};
let mut schedules = Vec::with_capacity(headers.len());
for (id, name, icon, mode, paused) in headers {
let targets = self.load_targets(&id)?;
let conditions = self.load_conditions(&id)?;
schedules.push(
codec::decode_schedule(
&id,
&name,
icon.as_deref(),
&mode,
&targets,
&conditions,
paused,
)
.map_err(StoreError::Decode)?,
);
}
Ok(schedules)
}
fn load_targets(&self, schedule_id: &str) -> Result<Vec<codec::TargetRow>, StoreError> {
let mut stmt = self
.conn
.prepare(
"SELECT ordinal, kind, value FROM schedule_target
WHERE schedule_id = ?1 ORDER BY ordinal",
)
.map_err(StoreError::Init)?;
let rows = stmt
.query_map([schedule_id], |row| {
Ok(codec::TargetRow { ordinal: row.get(0)?, kind: row.get(1)?, value: row.get(2)? })
})
.map_err(StoreError::Init)?;
let mut out = Vec::new();
for row in rows {
out.push(row.map_err(StoreError::Init)?);
}
Ok(out)
}
fn load_conditions(&self, schedule_id: &str) -> Result<Vec<codec::ConditionRow>, StoreError> {
let mut stmt = self
.conn
.prepare(
"SELECT ordinal, kind, days, start_min, end_min, limit_value, window,
ssid, latitude, longitude, radius_m, outside
FROM schedule_condition WHERE schedule_id = ?1 ORDER BY ordinal",
)
.map_err(StoreError::Init)?;
let rows = stmt
.query_map([schedule_id], |row| {
Ok(codec::ConditionRow {
ordinal: row.get(0)?,
kind: row.get(1)?,
days: row.get(2)?,
start_min: row.get(3)?,
end_min: row.get(4)?,
limit_value: row.get(5)?,
window: row.get(6)?,
ssid: row.get(7)?,
latitude: row.get(8)?,
longitude: row.get(9)?,
radius_m: row.get(10)?,
outside: row.get(11)?,
})
})
.map_err(StoreError::Init)?;
let mut out = Vec::new();
for row in rows {
out.push(row.map_err(StoreError::Init)?);
}
Ok(out)
}
/// Persist the full schedule set, replacing what was there.
///
/// Written as a single transaction so a crash cannot leave a half-applied
/// schedule list — which on a blocking tool would mean some targets blocked
/// and others not, with no way for the user to tell which.
pub fn save_schedules(
&mut self,
schedules: &[blocker_core::model::Schedule],
) -> Result<(), StoreError> {
let tx = self.conn.transaction().map_err(StoreError::Init)?;
// Children cascade on delete, so clearing the parents is enough and
// avoids a stale row outliving its schedule.
tx.execute("DELETE FROM schedule", []).map_err(StoreError::Init)?;
{
let mut insert_target = tx
.prepare(
"INSERT INTO schedule_target (schedule_id, ordinal, kind, value)
VALUES (?1, ?2, ?3, ?4)",
)
.map_err(StoreError::Init)?;
let mut insert_condition = tx
.prepare(
"INSERT INTO schedule_condition
(schedule_id, ordinal, kind, days, start_min, end_min, limit_value,
window, ssid, latitude, longitude, radius_m, outside)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
)
.map_err(StoreError::Init)?;
for (index, s) in schedules.iter().enumerate() {
tx.execute(
"INSERT INTO schedule (id, name, icon, mode, paused, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
rusqlite::params![
s.id,
s.name,
s.icon,
codec::mode_to_str(s.mode),
i64::from(s.paused),
i64::try_from(index).unwrap_or(i64::MAX),
],
)
.map_err(StoreError::Init)?;
for row in codec::encode_targets(&s.targets) {
insert_target
.execute(rusqlite::params![s.id, row.ordinal, row.kind, row.value])
.map_err(StoreError::Init)?;
}
for row in codec::encode_conditions(&s.conditions) {
insert_condition
.execute(rusqlite::params![
s.id,
row.ordinal,
row.kind,
row.days,
row.start_min,
row.end_min,
row.limit_value,
row.window,
row.ssid,
row.latitude,
row.longitude,
row.radius_m,
row.outside,
])
.map_err(StoreError::Init)?;
}
}
}
tx.commit().map_err(StoreError::Init)
}
/// Persist lock state, including the monotonic deadline a pending disable
/// request is waiting on (section 1.5.2.2).
pub fn save_lock(
&mut self,
state: &blocker_core::lock::LockState,
boot_id: BootId,
last_monotonic_ms: u64,
) -> Result<(), StoreError> {
let pending = state.pending.as_ref();
self.conn
.execute(
"INSERT INTO lock_config
(id, level, schedules_locked, blocks_uninstall, keyholder_code,
request_ready_at, request_window_closes_at, boot_id, last_monotonic_ms)
VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
ON CONFLICT(id) DO UPDATE SET
level = excluded.level,
schedules_locked = excluded.schedules_locked,
blocks_uninstall = excluded.blocks_uninstall,
keyholder_code = excluded.keyholder_code,
request_ready_at = excluded.request_ready_at,
request_window_closes_at = excluded.request_window_closes_at,
boot_id = excluded.boot_id,
last_monotonic_ms = excluded.last_monotonic_ms",
rusqlite::params![
codec::level_to_str(state.level),
i64::from(state.schedules_locked),
i64::from(state.blocks_uninstall()),
state.keyholder_code,
pending.map(|p| i64::try_from(p.ready_at.0).unwrap_or(i64::MAX)),
pending.map(|p| i64::try_from(p.window_closes_at.0).unwrap_or(i64::MAX)),
i64::try_from(boot_id.0).unwrap_or(i64::MAX),
i64::try_from(last_monotonic_ms).unwrap_or(i64::MAX),
],
)
.map(|_| ())
.map_err(StoreError::Init)
}
/// The monotonic deadline and boot id of a pending disable request.
///
/// Used on resume to distinguish "the daemon restarted" from "the machine
/// rebooted" (section 1.5.3), so a lock is resumed conservatively rather
/// than assumed to have continued.
pub fn load_pending_request(&self) -> Result<Option<(Monotonic, BootId)>, StoreError> {
let mut stmt = self
.conn
.prepare("SELECT request_ready_at, boot_id FROM lock_config WHERE id = 1")
.map_err(StoreError::Init)?;
let mut rows = stmt.query([]).map_err(StoreError::Init)?;
match rows.next().map_err(StoreError::Init)? {
None => Ok(None),
Some(row) => {
let ready: Option<i64> = row.get(0).map_err(StoreError::Init)?;
let boot: i64 = row.get(1).map_err(StoreError::Init)?;
Ok(ready.map(|r| {
(
Monotonic(u64::try_from(r.max(0)).unwrap_or(0)),
BootId(u64::try_from(boot.max(0)).unwrap_or(0)),
)
}))
}
}
}
/// Record one attempt to disable (section 1.5.2.5). Local only.
pub fn record_attempt(
&mut self,
at: Monotonic,
boot_id: BootId,
day_index: i64,
) -> Result<(), StoreError> {
self.conn
.execute(
"INSERT INTO disable_attempt (at_monotonic, boot_id, day_index) VALUES (?1, ?2, ?3)",
rusqlite::params![
i64::try_from(at.0).unwrap_or(i64::MAX),
i64::try_from(boot_id.0).unwrap_or(i64::MAX),
day_index,
],
)
.map(|_| ())
.map_err(StoreError::Init)?;
Ok(())
}
/// How many disable attempts were made on a given local day.
///
/// The block screen shows this as plain text — "You tried to turn this off 4
/// times today" — never as a shaming counter (section 1.5.2.5).
pub fn attempt_count(&self, day_index: i64) -> Result<u32, StoreError> {
let count: i64 = self
.conn
.query_row(
"SELECT COUNT(*) FROM disable_attempt WHERE day_index = ?1",
[day_index],
|r| r.get(0),
)
.map_err(StoreError::Init)?;
Ok(u32::try_from(count.max(0)).unwrap_or(u32::MAX))
}
/// Add foreground minutes to a daily bucket (section 1.2).
pub fn add_daily_usage(
&mut self,
day_index: i64,
target: &str,
minutes: u32,
) -> Result<(), StoreError> {
self.conn
.execute(
"INSERT INTO usage_daily (day_index, target, minutes) VALUES (?1, ?2, ?3)
ON CONFLICT(day_index, target) DO UPDATE SET
minutes = minutes + excluded.minutes",
rusqlite::params![day_index, target, i64::from(minutes)],
)
.map(|_| ())
.map_err(StoreError::Init)?;
Ok(())
}
/// Add a launch to a daily bucket (section 1.2).
pub fn add_daily_launch(&mut self, day_index: i64, target: &str) -> Result<(), StoreError> {
self.conn
.execute(
"INSERT INTO usage_daily (day_index, target, minutes, launches) VALUES (?1, ?2, 0, 1)
ON CONFLICT(day_index, target) DO UPDATE SET
launches = launches + 1",
rusqlite::params![day_index, target],
)
.map(|_| ())
.map_err(StoreError::Init)?;
Ok(())
}
/// Read a daily usage bucket.
pub fn load_daily_usage(&self, day_index: i64) -> Result<UsageRow, StoreError> {
let mut stmt = self
.conn
.prepare("SELECT target, minutes, launches FROM usage_daily WHERE day_index = ?1")
.map_err(StoreError::Init)?;
let mut rows = stmt.query([day_index]).map_err(StoreError::Init)?;
let mut out = UsageRow::default();
while let Some(row) = rows.next().map_err(StoreError::Init)? {
let target: String = row.get(0).map_err(StoreError::Init)?;
let minutes: i64 = row.get(1).map_err(StoreError::Init)?;
let launches: i64 = row.get(2).map_err(StoreError::Init)?;
out.minutes.insert(target.clone(), u32::try_from(minutes.max(0)).unwrap_or(u32::MAX));
out.launches.insert(target, u32::try_from(launches.max(0)).unwrap_or(u32::MAX));
}
Ok(out)
}
/// Local day index for a civil time, for bucketing usage.
pub fn day_index(now: CivilTime) -> i64 {
now.epoch_minutes.div_euclid(24 * 60)
}
/// Path this store was opened from.
pub fn path(&self) -> &Path {
&self.path
}
/// Run a maintenance step: drop usage rows older than `keep_days`.
///
/// Pruning is deliberate rather than unbounded — an app-blocker's usage
/// history is genuinely useful for statistics (section 1.6) but a table
/// that grows forever is a slow disk leak on a machine with 9.6 GB free.
pub fn prune_usage(&mut self, keep_days: i64) -> Result<u64, StoreError> {
// Relative to the NEWEST day, not the oldest.
//
// Anchoring on MIN(day_index) is the obvious version and it never prunes
// anything: the oldest row is by definition the one being tested, so the
// cutoff sits before it and the delete matches zero rows. The table then
// grows forever, which on a machine with 9.6 GB free is exactly the slow
// leak this is meant to prevent. Caught by old_usage_is_pruned.
let newest: i64 = self
.conn
.query_row("SELECT COALESCE(MAX(day_index), 0) FROM usage_daily", [], |r| {
r.get::<_, i64>(0)
})
.map_err(StoreError::Init)?;
// +1 so exactly `keep_days` rows survive: with cutoff = newest - keep_days
// and a strict `<`, the boundary day is kept as well, giving keep_days + 1.
let cutoff = newest - keep_days + 1;
let n = self
.conn
.execute("DELETE FROM usage_daily WHERE day_index < ?1", [cutoff])
.map_err(StoreError::Init)?;
Ok(n as u64)
}
}
/// One bucket of accumulated usage.
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct UsageRow {
/// Foreground minutes per target.
pub minutes: std::collections::BTreeMap<String, u32>,
/// Launch counts per target.
pub launches: std::collections::BTreeMap<String, u32>,
}
/// Schema, version 1.
///
/// Forward-only migrations. `user_version` gates them; downgrading would risk
/// losing lock state, which section 1.5.3 forbids and section 1.5.4 makes a
/// safety property.
///
/// The normalised shape of `schedule_target` and `schedule_condition` is
/// deliberate over storing JSON blobs: usage and target rows are queried by
/// target, and an index on a text column that happens to contain JSON is not a
/// plan. Encoding is handled in [`crate::codec`].
pub const SCHEMA: &str = r#"
CREATE TABLE IF NOT EXISTS schedule (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
icon TEXT,
mode TEXT NOT NULL CHECK (mode IN ('Blocklist', 'Allowlist')),
paused INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS schedule_target (
schedule_id TEXT NOT NULL REFERENCES schedule(id) ON DELETE CASCADE,
ordinal INTEGER NOT NULL,
kind TEXT NOT NULL CHECK (kind IN ('app', 'website', 'keyword')),
value TEXT NOT NULL,
PRIMARY KEY (schedule_id, ordinal)
);
CREATE TABLE IF NOT EXISTS schedule_condition (
schedule_id TEXT NOT NULL REFERENCES schedule(id) ON DELETE CASCADE,
ordinal INTEGER NOT NULL,
kind TEXT NOT NULL,
days TEXT, -- comma-separated, Monday = 0
start_min INTEGER,
end_min INTEGER,
limit_value INTEGER,
window TEXT,
ssid TEXT,
latitude REAL,
longitude REAL,
radius_m INTEGER,
outside INTEGER,
PRIMARY KEY (schedule_id, ordinal)
);
-- Section 1.5.3: lock state lives in the database, not in an editable plaintext
-- config file, so editing a file is not enough to defeat a lock.
CREATE TABLE IF NOT EXISTS lock_config (
id INTEGER PRIMARY KEY CHECK (id = 1),
level TEXT NOT NULL CHECK (level IN ('Normal', 'Strict', 'Stubborn')),
schedules_locked INTEGER NOT NULL,
blocks_uninstall INTEGER NOT NULL,
keyholder_code TEXT,
request_ready_at INTEGER,
request_window_closes_at INTEGER,
boot_id INTEGER NOT NULL,
last_monotonic_ms INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS disable_attempt (
id INTEGER PRIMARY KEY AUTOINCREMENT,
at_monotonic INTEGER NOT NULL,
boot_id INTEGER NOT NULL,
day_index INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS usage_daily (
day_index INTEGER NOT NULL,
target TEXT NOT NULL,
minutes INTEGER NOT NULL DEFAULT 0,
launches INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (day_index, target)
);
CREATE TABLE IF NOT EXISTS usage_hourly (
hour_index INTEGER NOT NULL,
target TEXT NOT NULL,
minutes INTEGER NOT NULL DEFAULT 0,
launches INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (hour_index, target)
);
CREATE INDEX IF NOT EXISTS usage_daily_target ON usage_daily (target);
CREATE INDEX IF NOT EXISTS usage_hourly_target ON usage_hourly (target);
CREATE INDEX IF NOT EXISTS disable_attempt_day ON disable_attempt (day_index);
"#;
pub mod codec;
#[cfg(test)]
mod tests;
+304
View File
@@ -0,0 +1,304 @@
//! Tests for the SQLite store.
//!
//! These use a temporary file rather than `:memory:` because WAL is enabled on
//! the connection and an in-memory database with WAL is a subtly different thing
//! than what ships.
#![allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
reason = "test fixtures: a failing assertion or a broken test is not a runtime \
condition to handle gracefully"
)]
use super::*;
use blocker_core::model::{Condition, Schedule, ScheduleMode, Target, TimeWindow, UsageWindow};
/// A unique temp path per test, since tests run in parallel and a shared
/// database would make them interfere.
fn temp_db(name: &str) -> PathBuf {
let mut p = std::env::temp_dir();
p.push(format!("blocker-test-{}-{name}.db", std::process::id()));
let _ = std::fs::remove_file(&p);
p
}
fn sample_schedule() -> Schedule {
Schedule {
id: "s1".into(),
name: "Evening Focus".into(),
icon: Some("/icons/focus.png".into()),
mode: ScheduleMode::Blocklist,
targets: vec![
Target::App("com.example.social".into()),
Target::Website("example.com".into()),
Target::Keyword("casino".into()),
],
conditions: vec![
Condition::Time(TimeWindow { days: vec![0, 1, 2, 3, 4], start: 22 * 60, end: 6 * 60 }),
Condition::UsageLimit { limit_minutes: 90, window: UsageWindow::Daily },
],
paused: false,
}
}
#[test]
fn schedules_round_trip_through_the_database() {
let path = temp_db("schedules");
let original = sample_schedule();
{
let mut store = Store::open(&path).expect("open");
store.save_schedules(std::slice::from_ref(&original)).expect("save");
}
{
let store = Store::open(&path).expect("reopen");
let loaded = store.load_schedules().expect("load");
assert_eq!(loaded.len(), 1);
assert_eq!(loaded[0], original, "a schedule must survive a restart intact");
}
let _ = std::fs::remove_file(&path);
}
#[test]
fn overnight_window_survives_a_restart() {
// The specific case worth its own test: if the overnight flag were lost in
// storage, the schedule would stop blocking at midnight *after a restart*,
// while appearing correct in the UI.
let path = temp_db("overnight");
let mut original = sample_schedule();
original.conditions =
vec![Condition::Time(TimeWindow { days: vec![0], start: 22 * 60, end: 6 * 60 })];
{
let mut store = Store::open(&path).expect("open");
store.save_schedules(std::slice::from_ref(&original)).expect("save");
}
{
let store = Store::open(&path).expect("reopen");
let loaded = store.load_schedules().expect("load");
match &loaded[0].conditions[0] {
Condition::Time(w) => {
assert!(
w.is_overnight(),
"start={} end={} must still read as overnight after a restart",
w.start,
w.end
);
assert_eq!(w.start, 22 * 60);
assert_eq!(w.end, 6 * 60);
}
other => panic!("condition type changed across a restart: {other:?}"),
}
}
let _ = std::fs::remove_file(&path);
}
#[test]
fn saving_replaces_rather_than_accumulates() {
let path = temp_db("replace");
let mut store = Store::open(&path).expect("open");
store.save_schedules(&[sample_schedule(), sample_schedule_with_id("s2")]).expect("save");
assert_eq!(store.load_schedules().unwrap().len(), 2);
store.save_schedules(&[sample_schedule()]).expect("save");
let loaded = store.load_schedules().expect("load");
assert_eq!(loaded.len(), 1, "a second save must replace, not append");
assert_eq!(loaded[0].id, "s1");
let _ = std::fs::remove_file(&path);
}
fn sample_schedule_with_id(id: &str) -> Schedule {
let mut s = sample_schedule();
s.id = id.to_owned();
s.name = format!("Schedule {id}");
s
}
#[test]
fn usage_accumulates_rather_than_overwrites() {
let path = temp_db("usage");
let mut store = Store::open(&path).expect("open");
store.add_daily_usage(20_731, "com.example.social", 30).unwrap();
store.add_daily_usage(20_731, "com.example.social", 25).unwrap();
store.add_daily_launch(20_731, "com.example.social").unwrap();
store.add_daily_launch(20_731, "com.example.social").unwrap();
let row = store.load_daily_usage(20_731).expect("load");
assert_eq!(row.minutes.get("com.example.social"), Some(&55));
assert_eq!(row.launches.get("com.example.social"), Some(&2));
// A different day is a different bucket.
assert!(store.load_daily_usage(20_732).unwrap().minutes.is_empty());
let _ = std::fs::remove_file(&path);
}
#[test]
fn a_pending_disable_request_survives_a_restart() {
// Section 1.5.2.2: filing a request must not lose the request on restart, or
// a user who waits out a 12-hour delay and restarts the machine would be
// back at square one with no explanation.
let path = temp_db("pending");
let state = blocker_core::lock::LockState {
level: blocker_core::lock::LockLevel::Stubborn,
schedules_locked: true,
pending: Some(blocker_core::lock::PendingRequest {
ready_at: Monotonic(12 * 3_600_000),
window_closes_at: Monotonic(12 * 3_600_000 + 600_000),
}),
cooldown_remaining: None,
attempts_today: 0,
keyholder_code: None,
};
{
let mut store = Store::open(&path).expect("open");
store.save_lock(&state, BootId(7), 1_000).expect("save");
}
{
let store = Store::open(&path).expect("reopen");
let (ready, boot) = store
.load_pending_request()
.expect("load")
.expect("a pending request must survive a restart");
assert_eq!(ready, Monotonic(12 * 3_600_000));
assert_eq!(boot, BootId(7), "the boot id must survive, so a reboot is detectable");
}
let _ = std::fs::remove_file(&path);
}
#[test]
fn no_pending_request_reads_as_none() {
let path = temp_db("nopending");
let store = Store::open(&path).expect("open");
assert_eq!(store.load_pending_request().unwrap(), None);
let _ = std::fs::remove_file(&path);
}
#[test]
fn disable_attempts_are_counted_per_day() {
// Section 1.5.2.5: the block screen shows "you tried 4 times today".
let path = temp_db("attempts");
let mut store = Store::open(&path).expect("open");
for _ in 0..4 {
store.record_attempt(Monotonic(1_000), BootId(1), 20_731).unwrap();
}
store.record_attempt(Monotonic(2_000), BootId(1), 20_732).unwrap();
assert_eq!(store.attempt_count(20_731).unwrap(), 4);
assert_eq!(store.attempt_count(20_732).unwrap(), 1);
assert_eq!(store.attempt_count(99_999).unwrap(), 0);
let _ = std::fs::remove_file(&path);
}
/// Section 1.5.4: a corrupt database must never leave a device unusable.
///
/// This is the single most important test in the crate. The requirement is
/// fail *open*: blocking stops, the user keeps a working device, and the damaged
/// file is kept for diagnosis.
#[test]
fn a_corrupt_database_fails_open() {
let path = temp_db("corrupt");
// Real bytes in place of the SQLite header. Enough to make the file
// unopenable or unreadable, which is what "corrupt" means in the field.
std::fs::write(&path, b"SQLite format 3\x00 this is not actually a database").unwrap();
let store = Store::open(&path).expect("open must succeed despite corruption");
assert!(store.recovered_from_corruption(), "the store must report that it recovered");
assert!(
store.load_schedules().unwrap().is_empty(),
"a recovered database starts empty, which blocks nothing — fail open"
);
assert!(
path.with_extension("corrupt").exists(),
"the damaged file must be kept for diagnosis, not deleted"
);
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(path.with_extension("corrupt"));
}
#[test]
fn a_truncated_database_fails_open() {
// A different failure mode from garbage bytes: a valid header with the
// content cut off. Corruption in the field is often truncation, from a
// power loss during a write.
let path = temp_db("truncated");
{
let mut store = Store::open(&path).expect("open");
store.save_schedules(&[sample_schedule()]).expect("save");
}
// Keep the first 200 bytes — a valid header, an unusable file.
let full = std::fs::read(&path).unwrap();
std::fs::write(&path, &full[..200.min(full.len())]).unwrap();
let store = Store::open(&path);
assert!(
store.is_ok(),
"a truncated database must still open, because failing to open would \
leave the device with no enforcement and no UI"
);
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(path.with_extension("corrupt"));
}
#[test]
fn opening_creates_the_directory() {
let mut nested = std::env::temp_dir();
nested.push(format!("blocker-test-{}-nested/state", std::process::id()));
let _ = std::fs::remove_dir_all(&nested);
let path = nested.join("blocker.db");
let store = Store::open(&path);
assert!(store.is_ok(), "a missing parent directory must not be fatal");
assert!(path.exists());
let _ = std::fs::remove_dir_all(&nested);
}
#[test]
fn old_usage_is_pruned() {
// An unbounded usage table is a slow disk leak, and this machine has 9.6 GB
// free. Statistics (section 1.6) need history, not infinite history.
let path = temp_db("prune");
let mut store = Store::open(&path).expect("open");
for day in 20_000..20_100 {
store.add_daily_usage(day, "com.example.social", 10).unwrap();
}
let removed = store.prune_usage(10).expect("prune");
assert_eq!(removed, 90, "90 of 100 days are outside the 10-day window");
let kept = store.load_daily_usage(20_099).unwrap();
assert!(!kept.minutes.is_empty(), "recent history must be kept");
let _ = std::fs::remove_file(&path);
}
#[test]
fn reopening_is_idempotent() {
// The migration must not run twice or clobber data.
let path = temp_db("idempotent");
let mut store = Store::open(&path).expect("open");
store.save_schedules(&[sample_schedule()]).expect("save");
drop(store);
for _ in 0..3 {
let store = Store::open(&path).expect("reopen");
assert_eq!(store.load_schedules().unwrap().len(), 1, "data must survive re-migration");
}
let _ = std::fs::remove_file(&path);
}