585fd30440
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>
277 lines
12 KiB
Rust
277 lines
12 KiB
Rust
//! Lock levels and the friction ladder (CLAUDE.md sections 1.4 and 1.5).
|
|
//!
|
|
//! This module is where "pain to turn off" stops being a slogan and becomes
|
|
//! arithmetic on a monotonic clock. The governing rule from section 1.5.1 is
|
|
//! that a level can be **raised instantly but lowered only by going through
|
|
//! that level's own disable flow** — so the state machine here is deliberately
|
|
//! asymmetric.
|
|
//!
|
|
//! # The invariant that matters
|
|
//!
|
|
//! Section 1.5.4: the longest possible disable path is capped at 24 hours, and
|
|
//! no lock may exist without an exit. Every wait in this module is bounded by a
|
|
//! constant below, and [`LockState::worst_case_wait`] is what the M1 tests
|
|
//! assert against that cap. Adding an unbounded wait here breaks the project's
|
|
//! primary safety guarantee, so the constants are `const` and the cap is
|
|
//! asserted rather than documented.
|
|
|
|
use crate::engine::{BootId, CivilTime, EngineInput, Monotonic};
|
|
|
|
/// Longest any single wait in the ladder may be. Section 1.5.4 caps the whole
|
|
/// disable path at 24 hours; this is the single largest component.
|
|
pub const MAX_WAIT_MS: u64 = 24 * 60 * 60 * 1000;
|
|
|
|
/// Longest possible wait in whole hours, for display (section 1.5.1 requires the
|
|
/// cost be shown in plain language). Declared as its own constant rather than
|
|
/// derived from [`MAX_WAIT_MS`] by division: the workspace denies
|
|
/// `cast_possible_truncation`, and a truncation here would misstate the cost the
|
|
/// user is asked to confirm.
|
|
pub const MAX_WAIT_HOURS: u32 = 24;
|
|
|
|
/// Default cooldown before the disable screen accepts input (section 1.5.2.1).
|
|
pub const DEFAULT_COOLDOWN_MINUTES: u32 = 10;
|
|
pub const DEFAULT_COOLDOWN_MS: u64 = (DEFAULT_COOLDOWN_MINUTES as u64) * 60_000;
|
|
|
|
/// Ceiling on a cooldown after escalation doubles it (section 1.5.2.4).
|
|
pub const MAX_COOLDOWN_MINUTES: u32 = 30;
|
|
pub const MAX_COOLDOWN_MS: u64 = (MAX_COOLDOWN_MINUTES as u64) * 60_000;
|
|
|
|
/// Default delay before a filed disable request takes effect (section 1.5.2.2).
|
|
pub const DEFAULT_TIME_LOCK_HOURS: u32 = 12;
|
|
pub const DEFAULT_TIME_LOCK_MS: u64 = (DEFAULT_TIME_LOCK_HOURS as u64) * 3_600_000;
|
|
|
|
/// Length of the confirmation window once the delay elapses.
|
|
pub const CONFIRMATION_WINDOW_MINUTES: u32 = 10;
|
|
pub const CONFIRMATION_WINDOW_MS: u64 = (CONFIRMATION_WINDOW_MINUTES as u64) * 60_000;
|
|
|
|
/// Length of the typing challenge at the Stubborn level (section 1.5.2.3).
|
|
pub const TYPING_CHALLENGE_CHARS: u32 = 300;
|
|
|
|
/// Escalation resets after this long with no attempts (section 1.5.2.4).
|
|
pub const ESCALATION_RESET_MS: u64 = 7 * 24 * 60 * 60 * 1000;
|
|
|
|
/// Compile-time proof that the 24-hour cap and its display form agree. If these
|
|
/// ever drift, the number shown to the user at activation would no longer be the
|
|
/// number actually enforced — which is exactly the lie section 1.5.1 forbids.
|
|
const _: () = assert!(MAX_WAIT_HOURS as u64 * 3_600_000 == MAX_WAIT_MS);
|
|
|
|
/// The three lock levels (section 1.5.1).
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
|
pub enum LockLevel {
|
|
/// Schedules edit and pause freely.
|
|
Normal,
|
|
/// AppBlock-equivalent Strict Mode (section 1.4): no editing, pausing or
|
|
/// uninstalling any schedule.
|
|
Strict,
|
|
/// Strict plus the full friction ladder.
|
|
Stubborn,
|
|
}
|
|
|
|
impl LockLevel {
|
|
/// The disable flow required to leave this level.
|
|
///
|
|
/// Section 1.5.1 requires the cost to be shown and confirmed at activation
|
|
/// time, so this is also what the activation screen renders as plain
|
|
/// language — never as a number the user has to interpret.
|
|
pub fn disable_cost(&self) -> DisableCost {
|
|
match self {
|
|
Self::Normal => DisableCost::Immediate,
|
|
Self::Strict => DisableCost::Cooldown { minutes: DEFAULT_COOLDOWN_MINUTES },
|
|
Self::Stubborn => DisableCost::TimeLockAndTyping {
|
|
wait_hours: DEFAULT_TIME_LOCK_HOURS,
|
|
type_chars: TYPING_CHALLENGE_CHARS,
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Human-readable cost of undoing a lock, shown at activation (section 1.5.1).
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum DisableCost {
|
|
/// The level can be left immediately.
|
|
Immediate,
|
|
/// A wait on the disable screen before it accepts input.
|
|
Cooldown {
|
|
/// How long the wait runs, in whole minutes.
|
|
minutes: u32,
|
|
},
|
|
/// A filed request that stays fully blocking, plus a typing challenge.
|
|
TimeLockAndTyping {
|
|
/// How long blocking continues before the request can be confirmed.
|
|
wait_hours: u32,
|
|
/// Length of the string that must be retyped exactly.
|
|
type_chars: u32,
|
|
},
|
|
}
|
|
|
|
/// A disable request that has been filed but not yet granted
|
|
/// (section 1.5.2.2 — "disabling doesn't disable, it files a request").
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct PendingRequest {
|
|
/// Monotonic deadline. Never wall clock: section 1.5.3 requires a system
|
|
/// date change to never shorten a delay.
|
|
pub ready_at: Monotonic,
|
|
/// The confirmation window close. Missing it cancels the request and the
|
|
/// user starts over.
|
|
pub window_closes_at: Monotonic,
|
|
}
|
|
|
|
/// One recorded attempt to disable (section 1.5.2.5). Local only.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct Attempt {
|
|
/// Monotonic time of the attempt.
|
|
pub at: Monotonic,
|
|
/// Which boot it happened in, so attempts spanning a reboot are not
|
|
/// miscounted as simultaneous.
|
|
pub boot_id: BootId,
|
|
}
|
|
|
|
/// The lock subsystem's state, as a pure function of its input.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct LockState {
|
|
/// The active lock level (section 1.5.1).
|
|
pub level: LockLevel,
|
|
/// True while any restriction from section 1.4 is in force: no schedule may
|
|
/// be edited, paused or deleted.
|
|
pub schedules_locked: bool,
|
|
/// A request is filed and blocking stays fully active (section 1.5.2.2).
|
|
pub pending: Option<PendingRequest>,
|
|
/// Cooldown on the disable screen. Leaving the screen resets it.
|
|
pub cooldown_remaining: Option<Monotonic>,
|
|
/// Every attempt, for the block screen to display (section 1.5.2.5).
|
|
pub attempts_today: u32,
|
|
/// The keyholder code, if the user chose that option at activation
|
|
/// (section 1.5.2.7). Generated once, shown once.
|
|
pub keyholder_code: Option<String>,
|
|
}
|
|
|
|
impl LockState {
|
|
/// Derive lock state from elapsed monotonic time. Pure: no clock read, so
|
|
/// the caller supplies [`EngineInput::monotonic`].
|
|
pub fn evaluate(input: &EngineInput<'_>) -> Self {
|
|
// M1 wires this to persisted lock configuration. The timing logic that
|
|
// matters — cooldown elapse, request windows, escalation — is written
|
|
// here against the monotonic field so it is testable without a clock.
|
|
let now = input.monotonic;
|
|
let _ = (now, input.boot_id, input.now);
|
|
Self {
|
|
level: LockLevel::Normal,
|
|
schedules_locked: false,
|
|
pending: None,
|
|
cooldown_remaining: None,
|
|
attempts_today: 0,
|
|
keyholder_code: None,
|
|
}
|
|
}
|
|
|
|
/// Whether a schedule may currently be edited (section 1.4).
|
|
///
|
|
/// Strict Mode locks **all** schedules, not only the one it was tied to —
|
|
/// that is the AppBlock-equivalent behaviour section 1.4 specifies.
|
|
pub fn may_edit_schedule(&self) -> bool {
|
|
!self.schedules_locked
|
|
}
|
|
|
|
/// Whether a schedule may be paused (section 1.4).
|
|
pub fn may_pause_schedule(&self) -> bool {
|
|
!self.schedules_locked
|
|
}
|
|
|
|
/// Whether the uninstall block applies. Section 1.4 allows this to be
|
|
/// configured independently of Strict Mode being on.
|
|
pub fn blocks_uninstall(&self) -> bool {
|
|
matches!(self.level, LockLevel::Strict | LockLevel::Stubborn)
|
|
}
|
|
|
|
/// The longest possible wait to fully disable, in milliseconds.
|
|
///
|
|
/// Section 1.5.4 caps this at 24 hours. Every term here is a `const` above,
|
|
/// so the bound is structural: to exceed 24 hours, someone would have to add
|
|
/// an unbounded wait to this module, and the M1 test asserts against
|
|
/// [`MAX_WAIT_MS`].
|
|
pub fn worst_case_wait(&self) -> u64 {
|
|
match self.level {
|
|
LockLevel::Normal => 0,
|
|
LockLevel::Strict => MAX_COOLDOWN_MS,
|
|
LockLevel::Stubborn => MAX_WAIT_MS,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Escalate the cooldown, doubling it but never past the cap
|
|
/// (section 1.5.2.4).
|
|
///
|
|
/// `attempted_at` and `now` are monotonic. `last_attempt_at` is `None` for a
|
|
/// first attempt.
|
|
pub fn escalate_cooldown(previous: Option<Monotonic>, now: Monotonic) -> u64 {
|
|
let previous_ms = previous.map_or(DEFAULT_COOLDOWN_MS, |p| now.0.saturating_sub(p.0));
|
|
previous_ms.saturating_mul(2).min(MAX_COOLDOWN_MS)
|
|
}
|
|
|
|
/// Whether an escalation has aged out (section 1.5.2.4: resets after 7 days
|
|
/// with no attempts).
|
|
pub fn escalation_has_reset(last_attempt_at: Option<Monotonic>, now: Monotonic) -> bool {
|
|
last_attempt_at.is_some_and(|t| now.0.saturating_sub(t.0) >= ESCALATION_RESET_MS)
|
|
}
|
|
|
|
/// Measure how far the wall clock has drifted from what real elapsed time
|
|
/// predicts.
|
|
///
|
|
/// `observed_wall` is what the OS now reports. `expected_wall` is what it should
|
|
/// report given `elapsed_monotonic` since the last sample. Returns `None` when
|
|
/// the clock agrees, and otherwise the signed skew in minutes: **positive means
|
|
/// the observed clock is behind**, i.e. the user moved the date backwards.
|
|
///
|
|
/// `elapsed_monotonic` is accepted for symmetry with the caller's sampling loop
|
|
/// but is not needed for the comparison — it is the *expected* wall value that
|
|
/// already accounts for it.
|
|
pub fn wall_clock_correction(
|
|
observed_wall: CivilTime,
|
|
expected_wall: CivilTime,
|
|
elapsed_monotonic: Monotonic,
|
|
) -> Option<i64> {
|
|
let _ = elapsed_monotonic;
|
|
let observed = observed_wall.epoch_minutes;
|
|
let expected = expected_wall.epoch_minutes;
|
|
if observed == expected {
|
|
return None;
|
|
}
|
|
Some(observed - expected)
|
|
}
|
|
|
|
/// Apply a detected wall-clock skew to a lock deadline.
|
|
///
|
|
/// **Always extends, never subtracts, and never past the cap.** Three rules, in
|
|
/// order of importance:
|
|
///
|
|
/// 1. *Never shortens.* Section 1.5.3. A user who moves the system date is
|
|
/// trying to shorten a wait, and an implementation that trusted the wall
|
|
/// clock would oblige them. The magnitude is taken unsigned so the direction
|
|
/// of the jump cannot matter.
|
|
/// 2. *Always extends when a jump is detected.* Section 1.5.3 again. The
|
|
/// engine cannot distinguish deliberate tampering from a machine that was
|
|
/// suspended longer than the monotonic clock accounted for, so it takes the
|
|
/// conservative branch and lengthens the lock.
|
|
/// 3. *Never past the cap.* Section 1.5.4 caps the longest possible disable
|
|
/// path at 24 hours. Without this clamp, rule 2 would let a 3-day clock
|
|
/// rollback turn a 12-hour lock into a 3-day wait — correct behaviour on the
|
|
/// anti-tamper axis and a violation on the safety axis.
|
|
///
|
|
/// Over-extending is the acceptable failure mode. A lock that ends slightly late
|
|
/// is an inconvenience; a lock that ends early defeats the entire mechanism.
|
|
/// The clamp bounds the cost of that choice.
|
|
pub fn apply_correction(deadline: Monotonic, correction_minutes: i64) -> Monotonic {
|
|
let extension_ms = correction_minutes.unsigned_abs().saturating_mul(60_000);
|
|
let extended = deadline.0.saturating_add(extension_ms);
|
|
Monotonic(extended.min(MAX_WAIT_MS))
|
|
}
|
|
|
|
/// Resume a lock after a reboot, conservatively (section 1.5.3).
|
|
///
|
|
/// A new boot id means we cannot know how much of the elapsed time was real. The
|
|
/// conservative reading is that less time passed than we hoped, so the deadline
|
|
/// is pushed out rather than assumed satisfied.
|
|
pub fn resume_after_reboot(deadline: Monotonic, elapsed_before_reboot: Monotonic) -> Monotonic {
|
|
Monotonic(deadline.0.max(elapsed_before_reboot.0))
|
|
}
|