//! 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"); }