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:
@@ -0,0 +1,36 @@
|
||||
[package]
|
||||
name = "blocker-tests"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Cross-platform scenario tests: golden decision tables that are the parity contract for every platform layer."
|
||||
license = "UNLICENSED"
|
||||
|
||||
[dependencies]
|
||||
blocker-core = { path = "../core" }
|
||||
|
||||
# NO serde HERE — YET.
|
||||
#
|
||||
# The golden tables are designed as JSON (see src/golden.rs), and JSON needs
|
||||
# serde/serde_json. Those crates are deliberately NOT added in M0, for two
|
||||
# reasons:
|
||||
#
|
||||
# 1. CLAUDE.md section 0 rule 3 requires stopping to ask before adding any
|
||||
# dependency not listed in section 3. serde is not on that list. It is
|
||||
# transitively required by rusqlite and uniffi (both listed), which is why
|
||||
# the design assumed it would be present — but "will be present later" is
|
||||
# not "approved to add now".
|
||||
#
|
||||
# 2. The development machine has 9.7 GB free. Fetching the registry is the
|
||||
# step that actually consumes disk, and the dependency-purity test needs a
|
||||
# Cargo.lock to exist at all.
|
||||
#
|
||||
# The format is specified in src/golden.rs as the contract M1 implements against.
|
||||
# The parse layer arrives in M1 alongside rusqlite, in one dependency change
|
||||
# with an ADR entry. See docs/DECISIONS.md ADR-3.
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[lib]
|
||||
name = "blocker_tests"
|
||||
path = "src/lib.rs"
|
||||
@@ -0,0 +1,242 @@
|
||||
//! The golden decision tables (CLAUDE.md section 7).
|
||||
//!
|
||||
//! A table is a list of cases. Each case is schedules plus an input plus an
|
||||
//! expected decision, and running the same table against the engine must
|
||||
//! reproduce the expectation exactly.
|
||||
//!
|
||||
//! # Format
|
||||
//!
|
||||
//! JSON, one file per concern, under `golden/`. JSON rather than TOML because
|
||||
//! `serde_json` is already in the dependency tree via `rusqlite` and `uniffi`
|
||||
//! (both listed in CLAUDE.md section 3), so TOML would add a crate for no gain.
|
||||
//!
|
||||
//! ```json
|
||||
//! {
|
||||
//! "concern": "overnight",
|
||||
//! "cases": [
|
||||
//! {
|
||||
//! "name": "mon 22:00-06:00 window still active at tue 03:00",
|
||||
//! "covers": "7:overnight time ranges",
|
||||
//! "schedules": [
|
||||
//! { "id": "s1", "name": "Night", "mode": "Blocklist",
|
||||
//! "targets": [{"type": "app", "app": "com.example.social"}],
|
||||
//! "conditions": [{"type": "Time", "days": [0],
|
||||
//! "start": "22:00", "end": "06:00"}] }
|
||||
//! ],
|
||||
//! "input": { "civil_time": "2026-10-06T03:00:00+02:00" },
|
||||
//! "expect": {
|
||||
//! "blocked_apps": [{"app": "com.example.social", "schedule": "s1"}],
|
||||
//! "next_wakeup": "2026-10-06T06:00:00+02:00"
|
||||
//! }
|
||||
//! }
|
||||
//! ]
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! # Status in M0
|
||||
//!
|
||||
//! The shapes below are the **contract**, written down before any parse layer
|
||||
//! exists so M1 implements against a fixed format rather than retrofitting one.
|
||||
//! `serde::Deserialize` is deliberately not derived yet — adding serde is a
|
||||
//! dependency decision (CLAUDE.md section 0 rule 3) and is deferred to M1,
|
||||
//! where it arrives with `rusqlite` under one ADR entry.
|
||||
//!
|
||||
//! # The clock-tampering exception
|
||||
//!
|
||||
//! Most cases are pure data. Clock tampering is not: proving that moving the
|
||||
//! system date never shortens a lock requires actually moving a clock, which a
|
||||
//! data file cannot express. Those cases live in [`crate::purity`] as code
|
||||
//! fixtures instead, and the two coexist deliberately — the tables describe
|
||||
//! decisions, the fixtures describe the passage of time.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// One table, deserialised from one `golden/*.json` file.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Table {
|
||||
/// Which concern this table covers, e.g. `overnight`.
|
||||
pub concern: String,
|
||||
/// The cases in the table.
|
||||
pub cases: Vec<Case>,
|
||||
}
|
||||
|
||||
/// One case: setup, input, expected decision.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Case {
|
||||
/// Human-readable, and the primary test failure message.
|
||||
pub name: String,
|
||||
/// Which CLAUDE.md section 7 scenario this covers, so coverage is auditable
|
||||
/// rather than assumed. Checked against [`MANDATORY_COVERAGE`].
|
||||
pub covers: String,
|
||||
/// The schedules under test.
|
||||
pub schedules: Vec<ScheduleSpec>,
|
||||
/// The input to the engine.
|
||||
pub input: InputSpec,
|
||||
/// The decision that must result.
|
||||
pub expect: ExpectSpec,
|
||||
}
|
||||
|
||||
/// A schedule as it appears in a table.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ScheduleSpec {
|
||||
/// Stable id, referenced by `BlockedSpec::schedule`.
|
||||
pub id: String,
|
||||
/// Display name, shown on the block screen.
|
||||
pub name: String,
|
||||
/// `"Blocklist"` or `"Allowlist"`.
|
||||
pub mode: String,
|
||||
/// What this schedule targets.
|
||||
pub targets: Vec<TargetSpec>,
|
||||
/// Its conditions. At least one must hold for the schedule to be active.
|
||||
pub conditions: Vec<ConditionSpec>,
|
||||
/// Defaults to false when absent from JSON.
|
||||
pub paused: bool,
|
||||
}
|
||||
|
||||
/// A target, tagged by type so the JSON is self-describing.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum TargetSpec {
|
||||
/// An app, by platform-native identifier.
|
||||
App {
|
||||
/// Package name on Android, desktop-file id or exe path elsewhere.
|
||||
app: String,
|
||||
},
|
||||
/// A registrable domain. Subdomains are implied.
|
||||
Website {
|
||||
/// e.g. `example.com`.
|
||||
website: String,
|
||||
},
|
||||
/// A case-insensitive substring matched against the URL.
|
||||
Keyword {
|
||||
/// The substring to match.
|
||||
keyword: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// A condition, tagged by type.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ConditionSpec {
|
||||
/// Block while local time is inside the window.
|
||||
Time {
|
||||
/// Weekdays on which the window **starts**. Monday = 0, matching
|
||||
/// `time::Weekday`.
|
||||
days: Vec<u8>,
|
||||
/// `HH:MM`. When `start > end` the window spans midnight — there is
|
||||
/// deliberately no `crosses_midnight` flag, because the comparison
|
||||
/// already encodes it and a second field is a second way to be wrong.
|
||||
start: String,
|
||||
/// `HH:MM`. See `start`.
|
||||
end: String,
|
||||
},
|
||||
/// Block after a foreground-time threshold.
|
||||
UsageLimit {
|
||||
/// Threshold in minutes.
|
||||
limit_minutes: u32,
|
||||
/// `"Daily"` or `"Hourly"`.
|
||||
window: String,
|
||||
},
|
||||
/// Block after a launch-count threshold.
|
||||
LaunchCount {
|
||||
/// Threshold count.
|
||||
limit: u32,
|
||||
/// `"Daily"` or `"Hourly"`.
|
||||
window: String,
|
||||
},
|
||||
/// Block while connected to a named network.
|
||||
WifiSsid {
|
||||
/// The SSID to match exactly.
|
||||
ssid: String,
|
||||
},
|
||||
/// Block inside or outside a radius. Mobile only (section 6.4).
|
||||
Location {
|
||||
/// Circle centre latitude in degrees.
|
||||
latitude: f64,
|
||||
/// Circle centre longitude in degrees.
|
||||
longitude: f64,
|
||||
/// Radius in metres.
|
||||
radius_meters: u32,
|
||||
/// Invert the test: block when *outside* the radius. Defaults false.
|
||||
block_when_outside: bool,
|
||||
},
|
||||
}
|
||||
|
||||
/// The engine input for a case.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct InputSpec {
|
||||
/// RFC 3339 with an explicit offset, e.g. `2026-10-06T03:00:00+02:00`.
|
||||
/// The offset is mandatory so a DST case is unambiguous.
|
||||
pub civil_time: String,
|
||||
/// Monotonic milliseconds since boot. Absent means 0.
|
||||
pub monotonic_ms: u64,
|
||||
/// Boot id. Absent means 0.
|
||||
pub boot_id: u64,
|
||||
/// Accumulated usage.
|
||||
pub usage: UsageSpec,
|
||||
/// Platform-observed context.
|
||||
pub context: ContextSpec,
|
||||
}
|
||||
|
||||
/// Usage counters for a case. All fields default to empty.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct UsageSpec {
|
||||
/// Foreground minutes per target, daily bucket.
|
||||
pub daily_minutes: BTreeMap<String, u32>,
|
||||
/// Foreground minutes per target, hourly bucket.
|
||||
pub hourly_minutes: BTreeMap<String, u32>,
|
||||
/// Launch counts per target, daily bucket.
|
||||
pub daily_launches: BTreeMap<String, u32>,
|
||||
/// Launch counts per target, hourly bucket.
|
||||
pub hourly_launches: BTreeMap<String, u32>,
|
||||
}
|
||||
|
||||
/// Platform context for a case.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ContextSpec {
|
||||
/// Current Wi-Fi SSID, if any.
|
||||
pub current_ssid: Option<String>,
|
||||
/// Current `(latitude, longitude)`, if any.
|
||||
pub current_location: Option<(f64, f64)>,
|
||||
}
|
||||
|
||||
/// The decision a case expects.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ExpectSpec {
|
||||
/// Apps that must be blocked, each attributed to a schedule.
|
||||
pub blocked_apps: Vec<BlockedSpec>,
|
||||
/// Websites and keywords that must be blocked.
|
||||
pub blocked_domains: Vec<BlockedSpec>,
|
||||
/// RFC 3339, or the literal `none` when the platform may sleep forever.
|
||||
pub next_wakeup: String,
|
||||
}
|
||||
|
||||
/// One expected blocked item.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BlockedSpec {
|
||||
/// The target that must be blocked.
|
||||
pub app: String,
|
||||
/// The id of the schedule that must block it.
|
||||
pub schedule: String,
|
||||
}
|
||||
|
||||
/// Every scenario in [`MANDATORY_COVERAGE`] must be claimed by at least one
|
||||
/// `covers` field across the tables, and `coverage.rs` asserts it — so a
|
||||
/// scenario cannot silently lose its test.
|
||||
pub const MANDATORY_COVERAGE: &[&str] = &[
|
||||
"7:overnight time ranges",
|
||||
"7:DST changes",
|
||||
"7:timezone changes",
|
||||
"7:clock tampering",
|
||||
"7:strictest-wins",
|
||||
"7:allowlist mode",
|
||||
"7:invalid condition combos",
|
||||
"7:strict mode lock rules",
|
||||
// The section 1.2 combination table, itemised so a missing pair is visible.
|
||||
"1.2:time+usage rejected",
|
||||
"1.2:time+launch rejected",
|
||||
"1.2:usage+launch rejected",
|
||||
"1.2:usage+location rejected",
|
||||
"1.2:launch+location rejected",
|
||||
// Section 1.5.4 safety guarantees.
|
||||
"1.5.4:never-block list is unblockable",
|
||||
"1.5.4:worst case 24h",
|
||||
];
|
||||
@@ -0,0 +1,37 @@
|
||||
//! Cross-platform scenario tests (CLAUDE.md section 7).
|
||||
//!
|
||||
//! This crate holds the **golden decision tables**: declarative schedules,
|
||||
//! inputs, and expected decisions that are run against the core engine.
|
||||
//!
|
||||
//! # Why this is a contract, not a test suite
|
||||
//!
|
||||
//! Section 7 requires that the same table runs against the core and serves as
|
||||
//! the parity contract for all platforms. Android, Linux and Windows each
|
||||
//! implement a mapping from a platform app identity to a `Target`, but they all
|
||||
//! hand the same [`blocker_core`] engine the same schedule set and get back the
|
||||
//! same decision. A platform crate runs this table against its own mapping, so
|
||||
//! if Linux disagrees with Android the build fails rather than the user filing
|
||||
//! a bug.
|
||||
//!
|
||||
//! # The rule that keeps it honest
|
||||
//!
|
||||
//! **A case is added before the engine changes.** If a table were written after
|
||||
//! the code, it would only record current behaviour and could never catch a
|
||||
//! regression — it would be a transcript, not a specification. Reviewing a PR
|
||||
//! that changes engine behaviour therefore means checking that a case was added
|
||||
//! or updated in the same commit.
|
||||
//!
|
||||
//! # Layout
|
||||
//!
|
||||
//! - `golden/` — the tables themselves, one JSON file per concern
|
||||
//! - [`purity`] — a mechanical check that the core has not gained I/O
|
||||
//!
|
||||
//! The mandatory case list is in [`golden::MANDATORY_COVERAGE`].
|
||||
//!
|
||||
//! [`purity`] is `cfg(test)` because its checks are `#[test]` functions in this
|
||||
//! library's unit-test build, not library API. They run under `cargo test`.
|
||||
|
||||
pub mod golden;
|
||||
|
||||
#[cfg(test)]
|
||||
mod purity;
|
||||
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user