9a5c529a8e
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>
202 lines
7.0 KiB
Rust
202 lines
7.0 KiB
Rust
//! 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);
|
|
}
|