c723efb186
Establishes the cargo workspace and the shared `blocker-core` crate. The engine is deliberately written before any platform code, because CLAUDE.md section 2 makes it the single source of decision truth and M1 acceptance requires it to be pure. **Workspace** — five crates plus a test crate, zero external dependencies. Section 3 requires every crate to justify its binary-size cost, so M0 adds none. [profile.release] is workspace-only (cargo rejects it in members) and carries the section 5 settings that make the 5 MB stripped budget reachable. **Purity contract** — `evaluate(&EngineInput) -> Decision` reads no clock, no disk and no OS API; everything arrives in the input. `tests/purity.rs` enforces this mechanically by asserting blocker-core has zero dependencies, so a stray I/O reach-in fails the build rather than silently making the golden tables useless as a parity contract. Decisions worth calling out: - **Monotonic time is a separate type from wall time.** Section 1.5.3 requires a system date change never to shorten a lock. Keeping them apart makes that structural rather than a rule to remember. - **`start > end` means overnight.** No `crosses_midnight` flag: the comparison already encodes it and a second field is a second way to be wrong. The weekday for a window that started yesterday evening is yesterday's, which `overnight_window_survives_past_midnight` pins down. - **`next_wakeup` is armed over every non-paused schedule, not only active ones.** Filtering to active conditions first would let the daemon sleep straight through the moment a schedule is due to start. - **Allowlist needs the installed-app inventory.** `EngineInput` carries `installed_apps`; an empty slice fails open rather than guessing. - **Wall-clock correction extends, never shortens, and clamps to the 24h cap.** Extending unbounded would satisfy anti-tamper and violate section 1.5.4. - **`cast_possible_truncation` is denied workspace-wide.** Truncating a millisecond counter would corrupt a lock timer. Verified: 22 tests pass, `cargo fmt --check` clean, `cargo clippy -D warnings` clean, stripped release daemon 0.279 MB against the 5 MB budget. Co-Authored-By: Claude Code <noreply@anthropic.com>
93 lines
3.7 KiB
Rust
93 lines
3.7 KiB
Rust
//! Storage: SQLite-backed persistence (CLAUDE.md section 3).
|
|
//!
|
|
//! Section 1.5.3 requires lock and Strict state to live in the core database
|
|
//! rather than in editable plaintext config, so that editing a file is not
|
|
//! enough to defeat a lock. The schema therefore keeps schedules, lock state,
|
|
//! and usage counters in one place with no config file beside them.
|
|
//!
|
|
//! # Deliberately thin in M0
|
|
//!
|
|
//! rusqlite is not yet a dependency — the machine this was built on has 9.7 GB
|
|
//! free and section 3 makes every crate justify its size cost. This module
|
|
//! defines the schema and the statements M1 will use, with no I/O behind it
|
|
//! yet, so the schema is reviewable before it is compiled.
|
|
|
|
/// The schema, version 1.
|
|
///
|
|
/// Applied by `PRAGMA user_version`. Migrations are additive and forward-only;
|
|
/// downgrading a schema would risk losing lock state, which section 1.5.4 does
|
|
/// not permit.
|
|
pub const SCHEMA_VERSION: i64 = 1;
|
|
|
|
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')),
|
|
targets TEXT NOT NULL, -- JSON array of Target
|
|
conditions TEXT NOT NULL, -- JSON array of Condition
|
|
paused INTEGER NOT NULL DEFAULT 0,
|
|
created_at INTEGER NOT NULL
|
|
);
|
|
|
|
-- Section 1.5.3: lock state lives in the DB, not in a plaintext config file,
|
|
-- so that editing a file cannot 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, -- NULL if the user declined section 1.5.2.7
|
|
-- Monotonic deadlines for a filed disable request, section 1.5.2.2.
|
|
-- NULL when no request is pending.
|
|
request_ready_at INTEGER,
|
|
request_window_closes_at INTEGER,
|
|
-- Elapsed-so-far plus boot id, so a reboot resumes conservatively.
|
|
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 -- for the "4 times today" count
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS usage_daily (
|
|
day_index INTEGER NOT NULL, -- days since epoch, local
|
|
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, -- hours since epoch, local
|
|
target TEXT NOT NULL,
|
|
minutes INTEGER NOT NULL DEFAULT 0,
|
|
launches INTEGER NOT NULL DEFAULT 0,
|
|
PRIMARY KEY (hour_index, target)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS meta (
|
|
key TEXT PRIMARY KEY,
|
|
value TEXT NOT NULL
|
|
);
|
|
|
|
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);
|
|
"#;
|
|
|
|
/// Fail open on corruption (CLAUDE.md section 1.5.4).
|
|
///
|
|
/// A corrupt database must never leave a device unusable, so recovery renames
|
|
/// the bad file aside and starts clean rather than failing to launch. The
|
|
/// damaged file is kept for diagnosis.
|
|
///
|
|
/// `None` here means "fresh database", which is the fail-open outcome.
|
|
pub const RECOVERY_NOTE: &str =
|
|
"corrupt database renamed aside; blocking re-applied from defaults on next start";
|