feat(core): add workspace skeleton and the pure decision engine

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>
This commit is contained in:
2026-10-01 15:02:20 +03:00
parent 440b4748f2
commit 585fd30440
25 changed files with 2794 additions and 31 deletions
+261
View File
@@ -0,0 +1,261 @@
//! Mechanical proof of the engine purity contract (CLAUDE.md section 2).
//!
//! The engine must be a pure function of its inputs — no clock reads, no I/O,
//! no OS calls. That rule is what guarantees Android, Linux and Windows reach
//! identical decisions, and therefore what makes the golden tables a parity
//! contract rather than a wish.
//!
//! # Why this is a test and not a code review
//!
//! Purity is easy to break by accident and hard to notice. A `SystemTime::now()`
//! added to a helper looks harmless, and the platform it breaks is whichever one
//! nobody is testing that day. A mechanical check fails loudly at build time
//! instead. `dependency_purity` below reads the *resolved* dependency graph, so
//! it cannot be fooled by a transitive addition either.
//!
//! # Why `expect` appears here and nowhere else
//!
//! The workspace denies `unwrap_used` and `expect_used` outright (CLAUDE.md
//! section 0 rule 5: no `unwrap` on fallible OS or I/O results). Those lints are
//! right for shipping code and wrong for test fixtures, where a missing
//! `Cargo.lock` or an absent `NEVER_BLOCK` entry means the test itself is
//! broken and there is nothing to handle gracefully. The `allow` below is scoped
//! to this module, with the reason stated, so the exemption is visible in review
//! rather than being a workspace-wide hole.
#![cfg(test)]
#![allow(
clippy::unwrap_used,
clippy::expect_used,
reason = "test fixtures: a missing file or empty list is a broken test, not a runtime condition"
)]
//! # The clock-tampering fixtures live here
//!
//! Section 1.5.3 requires that changing the system date can never shorten a
//! lock. Proving that needs a clock that moves, which a JSON table cannot
//! express, so these are code fixtures rather than data cases. Both kinds
//! coexist deliberately: the tables describe *decisions*, this module describes
//! *the passage of time*.
use blocker_core::engine::{BootId, CivilTime, Monotonic};
use blocker_core::lock::{self, LockLevel};
/// The core crate must have no dependencies at all.
///
/// This is the strongest form of the check. The core currently needs nothing
/// from the ecosystem — time arithmetic is done on `i64` minutes — so any
/// dependency appearing here is either a bug (something I/O reached in) or a
/// decision that belongs in a documented ADR rather than a silent addition.
#[test]
fn dependency_purity() {
let cargo_lock = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/../Cargo.lock"))
.expect("Cargo.lock is generated by any cargo build");
let blocker_core = cargo_lock
.split("[[package]]")
.find(|block| block.contains("name = \"blocker-core\""))
.expect("blocker-core must appear in Cargo.lock");
let deps: Vec<&str> = blocker_core
.lines()
.skip_while(|line| !line.contains("dependencies = ["))
.skip(1)
.take_while(|line| !line.trim_start().starts_with(']'))
.map(|line| line.trim().trim_matches('"'))
.collect();
assert!(
deps.is_empty(),
"blocker-core must have zero dependencies to stay pure, found: {deps:?}\n\
A core dependency is either an I/O reach-in that breaks cross-platform \
parity, or an undocumented crate. Justify it in docs/DECISIONS.md first."
);
}
/// Moving the system clock backwards must not shorten a lock.
///
/// The concrete attack: a 12-hour time-lock request is running, and the user
/// sets the system date back three days hoping the request expires instantly.
#[test]
fn backwards_wall_clock_never_shortens_a_lock() {
let deadline = Monotonic(12 * 3_600_000); // 12h of monotonic time left
// Wall clock observed three days *behind* what elapsed time predicts.
let observed = CivilTime { epoch_minutes: 0, utc_offset_minutes: 0 };
let expected = CivilTime { epoch_minutes: 3 * 24 * 60, utc_offset_minutes: 0 };
let correction = lock::wall_clock_correction(observed, expected, Monotonic(0))
.expect("a three-day jump must be detected");
assert!(
correction < 0,
"an observed clock behind expectation is reported as a negative correction"
);
let adjusted = lock::apply_correction(deadline, correction);
assert!(
adjusted.0 >= deadline.0,
"a backwards wall-clock jump must not shorten the lock: \
12h deadline became {}ms",
adjusted.0
);
}
/// A forward jump must not shorten the lock either, and neither may push it past
/// the 24-hour cap — extending without a bound would violate section 1.5.4 even
/// though it satisfies the anti-tamper rule.
#[test]
fn wall_clock_correction_respects_both_bounds() {
let deadline = Monotonic(12 * 3_600_000);
// Forward jump of three days. Still must not shorten.
let forward = lock::apply_correction(deadline, 3 * 24 * 60);
assert!(forward.0 >= deadline.0, "a forward jump must never shorten a lock either");
// But the result is clamped: a 3-day rollback must not turn a 12h lock into
// a 3-day wait (section 1.5.4 caps the worst case at 24h).
assert!(
forward.0 <= lock::MAX_WAIT_MS,
"the corrected deadline {}ms exceeds the 24h cap {}ms",
forward.0,
lock::MAX_WAIT_MS
);
assert_eq!(forward.0, lock::MAX_WAIT_MS, "12h + 3 days clamps to the cap");
// A zero correction leaves the deadline alone.
assert_eq!(lock::apply_correction(deadline, 0), deadline);
}
/// No detected jump means no correction at all — an honest clock must not cost
/// the user any extra waiting.
#[test]
fn an_undisturbed_clock_costs_nothing() {
let t = CivilTime { epoch_minutes: 1_000_000, utc_offset_minutes: 60 };
assert_eq!(
lock::wall_clock_correction(t, t, Monotonic(5_000)),
None,
"an unchanged clock must report no correction"
);
}
/// A reboot must not reset a lock.
#[test]
fn reboot_resumes_conservatively() {
// 5h of monotonic time had elapsed before the reboot; the lock had 3h left.
let deadline_before_reboot = Monotonic(12 * 3_600_000);
let elapsed_before_reboot = Monotonic(5 * 3_600_000);
let resumed = lock::resume_after_reboot(deadline_before_reboot, elapsed_before_reboot);
assert!(
resumed >= deadline_before_reboot || resumed >= elapsed_before_reboot,
"resume must take the later of the two, never the earlier"
);
assert_eq!(
resumed.0, deadline_before_reboot.0,
"with 7h elapsed and 12h set, the remaining 7h is still owed after reboot"
);
}
/// Everything on the never-block list is unblockable, and ordinary apps are not
/// swept up by the substring match.
///
/// The list is per-platform (CLAUDE.md section 1.5.4 names phone/dialer and
/// emergency paths on mobile, and the OS shell plus Settings basics on
/// desktop), so the test iterates the list this build actually has rather than
/// assuming a global one.
#[test]
fn never_block_list_is_unblockable() {
assert!(
!blocker_core::never_block::NEVER_BLOCK.is_empty(),
"every supported platform must protect something; an empty list would mean \
a device could be locked out of its own dialler or shell"
);
for protected in blocker_core::never_block::NEVER_BLOCK {
assert!(
blocker_core::never_block::is_never_blocked(protected),
"{protected} is on the list and must therefore be unblockable"
);
}
// Substring matching must not over-match into unrelated apps.
for ordinary in ["com.example.social", "org.example.chat", "steam"] {
assert!(
!blocker_core::never_block::is_never_blocked(ordinary),
"{ordinary} is an ordinary app and must remain blockable"
);
}
}
/// The never-block check is consulted inside the engine, so a schedule naming a
/// protected app cannot produce a block entry for it.
#[test]
fn engine_cannot_block_a_never_blocked_app() {
use blocker_core::engine::{CivilTime, EngineInput, Monotonic, PlatformContext, UsageSnapshot};
use blocker_core::model::{Condition, Schedule, ScheduleMode, Target, TimeWindow};
// On this platform the list contains the desktop shell and Settings. Pick
// the first entry that actually exists for this build.
let protected =
blocker_core::never_block::NEVER_BLOCK.first().expect("non-empty list").to_string();
let schedules = vec![Schedule {
id: "s1".into(),
name: "Attempts to block the shell".into(),
icon: None,
mode: ScheduleMode::Blocklist,
targets: vec![Target::App(protected.clone())],
conditions: vec![Condition::Time(TimeWindow {
days: vec![0, 1, 2, 3, 4, 5, 6],
start: 0,
end: 1439,
})],
paused: false,
}];
let usage = UsageSnapshot::default();
let context = PlatformContext::default();
let input = EngineInput {
now: CivilTime { epoch_minutes: 0, utc_offset_minutes: 0 },
monotonic: Monotonic(0),
boot_id: BootId(0),
schedules: &schedules,
usage: &usage,
context: &context,
installed_apps: &[],
};
let decision = blocker_core::evaluate(&input);
assert!(
decision.blocked_apps.is_empty(),
"the engine produced a block for {protected}, which is on the never-block \
list and must be structurally unblockable (section 1.5.4)"
);
}
/// The worst-case disable path is capped at 24 hours.
#[test]
fn worst_case_disable_path_is_capped() {
for level in [LockLevel::Normal, LockLevel::Strict, LockLevel::Stubborn] {
let state = blocker_core::lock::LockState {
level,
schedules_locked: false,
pending: None,
cooldown_remaining: None,
attempts_today: 0,
keyholder_code: None,
};
assert!(
state.worst_case_wait() <= lock::MAX_WAIT_MS,
"{level:?} worst case {}ms exceeds the 24h cap",
state.worst_case_wait()
);
}
assert_eq!(lock::MAX_WAIT_MS, 24 * 3_600_000, "the cap is exactly 24 hours");
}
/// A boot id change is distinguishable from a daemon restart.
#[test]
fn boot_id_identifies_a_reboot() {
let first = BootId(0xDEAD_BEEF);
assert_ne!(first, BootId(0xDEAD_BEEE), "a reboot yields a different boot id");
}