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>
348 lines
13 KiB
Rust
348 lines
13 KiB
Rust
//! 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;
|