From 4b3e850fefca8308a2c5ec9a5f6cc0da86439401 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 9 Oct 2026 15:58:29 +0200 Subject: [PATCH] mind-body: the operator's brake and burn-rate dial, the reader and the mind-control CLI Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 166 ++++++++++++++++- Cargo.toml | 1 + crates/mind-body/Cargo.toml | 34 ++++ crates/mind-body/src/cli.rs | 117 ++++++++++++ crates/mind-body/src/control.rs | 231 +++++++++++++++++++++++ crates/mind-body/src/lib.rs | 9 + crates/mind-body/src/main.rs | 21 +++ crates/mind-body/tests/cli.rs | 101 ++++++++++ crates/mind-body/tests/control.rs | 295 ++++++++++++++++++++++++++++++ 9 files changed, 971 insertions(+), 4 deletions(-) create mode 100644 crates/mind-body/Cargo.toml create mode 100644 crates/mind-body/src/cli.rs create mode 100644 crates/mind-body/src/control.rs create mode 100644 crates/mind-body/src/lib.rs create mode 100644 crates/mind-body/src/main.rs create mode 100644 crates/mind-body/tests/cli.rs create mode 100644 crates/mind-body/tests/control.rs diff --git a/Cargo.lock b/Cargo.lock index 4fcc88d..01c68ba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -63,6 +63,12 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + [[package]] name = "async-trait" version = "0.1.92" @@ -113,6 +119,30 @@ dependencies = [ "generic-array", ] +[[package]] +name = "borsh" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.5", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -141,6 +171,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + [[package]] name = "chrono" version = "0.4.45" @@ -246,7 +282,7 @@ dependencies = [ "cultcache-rs", "ed25519-dalek", "hmac", - "rand", + "rand 0.9.5", "reed-solomon-erasure", "rmp-serde", "rmpv", @@ -372,6 +408,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "errno" version = "0.3.14" @@ -592,6 +634,12 @@ dependencies = [ "ahash", ] +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + [[package]] name = "hmac" version = "0.12.1" @@ -683,6 +731,16 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + [[package]] name = "inout" version = "0.1.4" @@ -757,7 +815,7 @@ version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e999beba7b6e8345721bd280141ed958096a2e4abdf74f67ff4ce49b4b54e47a" dependencies = [ - "hashbrown", + "hashbrown 0.12.3", ] [[package]] @@ -766,6 +824,19 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "mind-body" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "cultcache-rs", + "eureka-pipeline", + "rust_decimal", + "serde", + "tempfile", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -861,6 +932,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -891,16 +971,37 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + [[package]] name = "rand" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ - "rand_chacha", + "rand_chacha 0.9.0", "rand_core 0.9.5", ] +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + [[package]] name = "rand_chacha" version = "0.9.0" @@ -1044,6 +1145,23 @@ dependencies = [ "serde_bytes", ] +[[package]] +name = "rust_decimal" +version = "1.43.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7653272e75dcac41dc199fbea6f5797633994fafd339943c06c9af16bf29cd3a" +dependencies = [ + "arrayvec", + "borsh", + "bytes", + "num-traits", + "rand 0.8.8", + "rand 0.9.5", + "serde", + "serde_json", + "wasm-bindgen", +] + [[package]] name = "rustc_version" version = "0.4.1" @@ -1299,7 +1417,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix", "windows-sys 0.61.2", @@ -1361,6 +1479,36 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml_datetime" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.17+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow", +] + [[package]] name = "tracing" version = "0.1.44" @@ -1486,6 +1634,7 @@ dependencies = [ "cfg-if", "once_cell", "rustversion", + "serde", "wasm-bindgen-macro", "wasm-bindgen-shared", ] @@ -1694,6 +1843,15 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen" version = "0.57.1" diff --git a/Cargo.toml b/Cargo.toml index d1001dd..58edea6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "crates/huginn-mind", "crates/huginn-daemon", "crates/eureka-state", + "crates/mind-body", ] resolver = "3" diff --git a/crates/mind-body/Cargo.toml b/crates/mind-body/Cargo.toml new file mode 100644 index 0000000..7d43e39 --- /dev/null +++ b/crates/mind-body/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "mind-body" +version = "0.1.0" +edition.workspace = true +license.workspace = true +publish.workspace = true + +# One crate for her organs (rationale "One crate for her organs"). It holds the +# operator's brake and burn-rate dial today: a root-owned per-instance CultCache +# store (`control`), the operator's CLI over it (`cli`, the `mind-control` +# binary) and the one fail-closed reader every acting organ calls +# (`control::read_effective`). The waker joins this crate in cut mind-wake. +# +# `rust_decimal` is the dial's number type: heat, cadence and the run cap are +# money-adjacent and the tests pin exact products, which a float would blur. It +# serialises as a string, so the stored document carries no float. +[dependencies] +anyhow = "1" +chrono = { version = "0.4.44", features = ["serde"] } +cultcache-rs.workspace = true +eureka-pipeline = { path = "../eureka-pipeline" } +rust_decimal = { version = "1", default-features = false, features = ["std", "serde"] } +serde = { version = "1", features = ["derive"] } + +[dev-dependencies] +tempfile = "3" + +[lib] +name = "mind_body" +path = "src/lib.rs" + +[[bin]] +name = "mind-control" +path = "src/main.rs" diff --git a/crates/mind-body/src/cli.rs b/crates/mind-body/src/cli.rs new file mode 100644 index 0000000..5c4e013 --- /dev/null +++ b/crates/mind-body/src/cli.rs @@ -0,0 +1,117 @@ +//! The `mind-control` command line: the operator's hand on one instance's +//! control store. It performs no permission check of its own: the file is +//! root-owned and the filesystem is the authority. +//! +//! ```text +//! mind-control --instance show +//! mind-control --instance brake hold +//! mind-control --instance brake release +//! mind-control --instance dial set --heat H --base-cooldown-s S --base-run-usd U +//! ``` + +use std::io::Write; +use std::path::Path; +use std::str::FromStr; + +use anyhow::{Context, Result, anyhow, bail}; +use chrono::{DateTime, Utc}; +use eureka_pipeline::Slug; +use rust_decimal::Decimal; + +use crate::control::{BurnRate, ControlWriter, FileSource, control_path, load_state, read_effective}; + +const USAGE: &str = + "usage: mind-control --instance show | brake hold | brake release | dial set --heat H --base-cooldown-s S --base-run-usd U"; + +/// Who made the change: `SUDO_USER`, else `USER`, else `unknown`. An empty +/// variable counts as unset. +pub fn operator_name(sudo_user: Option, user: Option) -> String { + [sudo_user, user] + .into_iter() + .flatten() + .find(|name| !name.is_empty()) + .unwrap_or_else(|| "unknown".to_string()) +} + +/// Runs one command against `//control.cc`. The root, the +/// operator's name, the clock and the output are parameters so a test drives +/// the whole command without touching `/etc`. +pub fn run(args: &[String], root: &Path, set_by: &str, now: DateTime, out: &mut impl Write) -> Result<()> { + let (instance, command) = match args { + [flag, instance, command @ ..] if flag == "--instance" => (Slug(instance.clone()), command), + _ => bail!("{USAGE}"), + }; + let path = control_path(root, &instance)?; + let words: Vec<&str> = command.iter().map(String::as_str).collect(); + match words.as_slice() { + ["show"] => show(&path, &instance, out), + ["brake", "hold"] => ControlWriter::open(&path)?.set_brake(false, now, set_by), + ["brake", "release"] => ControlWriter::open(&path)?.set_brake(true, now, set_by), + ["dial", "set", flags @ ..] => { + let dial = parse_dial(flags, now, set_by)?; + ControlWriter::open(&path)?.set_dial(dial) + } + _ => bail!("{USAGE}"), + } +} + +fn parse_dial(flags: &[&str], now: DateTime, set_by: &str) -> Result { + let (mut heat, mut cooldown, mut run_usd) = (None, None, None); + let mut rest = flags.iter(); + while let Some(flag) = rest.next() { + let value = rest.next().ok_or_else(|| anyhow!("{flag} needs a value"))?; + let slot = match *flag { + "--heat" => &mut heat, + "--base-cooldown-s" => &mut cooldown, + "--base-run-usd" => &mut run_usd, + other => bail!("unknown flag {other:?}"), + }; + if slot.replace(*value).is_some() { + bail!("{flag} given twice"); + } + } + let need = |value: Option<&str>, name: &str| value.ok_or_else(|| anyhow!("{name} is required")); + Ok(BurnRate { + heat: Decimal::from_str(need(heat, "--heat")?).context("--heat is not a decimal")?, + base_cooldown_s: need(cooldown, "--base-cooldown-s")? + .parse() + .context("--base-cooldown-s is not a whole number of seconds")?, + base_run_usd: Decimal::from_str(need(run_usd, "--base-run-usd")?).context("--base-run-usd is not a decimal")?, + set_at: now, + set_by: set_by.to_string(), + }) +} + +fn show(path: &Path, instance: &Slug, out: &mut impl Write) -> Result<()> { + let source = FileSource::new(path); + writeln!(out, "instance: {}", instance.0)?; + match load_state(&source) { + Ok(state) => { + match &state.brake { + Some(brake) => writeln!( + out, + "brake: {} (set_at {}, set_by {})", + if brake.released { "released" } else { "held" }, + brake.set_at.to_rfc3339(), + brake.set_by + )?, + None => writeln!(out, "brake: absent")?, + } + match &state.dial { + Some(dial) => writeln!( + out, + "dial: heat {} base_cooldown_s {} base_run_usd {} (set_at {}, set_by {})", + dial.heat, + dial.base_cooldown_s, + dial.base_run_usd, + dial.set_at.to_rfc3339(), + dial.set_by + )?, + None => writeln!(out, "dial: absent")?, + } + } + Err(error) => writeln!(out, "store: undecodable ({error:#})")?, + } + writeln!(out, "effective: {:?}", read_effective(&source))?; + Ok(()) +} diff --git a/crates/mind-body/src/control.rs b/crates/mind-body/src/control.rs new file mode 100644 index 0000000..3806d98 --- /dev/null +++ b/crates/mind-body/src/control.rs @@ -0,0 +1,231 @@ +//! The operator's brake and burn-rate dial, and the one reader of them. +//! +//! State is a CultCache single-file store at +//! `/etc/gamecult/minds//control.cc`, root-owned and world-readable. +//! The operator's CLI replaces a document whole; every organ that acts calls +//! `read_effective` before the consequence and takes its answer as given. The +//! cadence and the run cap are derived here and nowhere else. +//! +//! Fail closed: the reader's only route to `Released` is a decodable store +//! holding a released brake and an in-bounds dial. Anything else (no file, no +//! brake, a held brake, no dial, a dial out of bounds, bytes that do not +//! decode, a file that cannot be read) is `Held`, with the reason. +//! +//! The reader opens the file read-only through CultCache's read-only snapshot +//! door: it takes no lock and creates no sibling file, so the units she runs as +//! need no write authority anywhere under `/etc`. Writing is the operator's; +//! the filesystem is the authority and nothing here checks permission. + +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use anyhow::{Context, Result}; +use chrono::{DateTime, Utc}; +use cultcache_rs::{CultCache, CultCacheEnvelope, DatabaseEntry, SingleFileMessagePackBackingStore}; +use eureka_pipeline::Slug; +use rust_decimal::Decimal; +use rust_decimal::prelude::ToPrimitive; + +/// Where every instance's control directory lives. Tests and the library never +/// touch it: the root is a parameter. +pub const CONTROL_ROOT: &str = "/etc/gamecult/minds"; + +/// The one key each control document has in the store. +pub const BRAKE_KEY: &str = "brake"; +pub const DIAL_KEY: &str = "dial"; + +/// `//control.cc`, after the leaf's slug grammar has passed, so +/// an instance name cannot walk out of the root. +pub fn control_path(root: &Path, instance: &Slug) -> Result { + instance + .validate_slug() + .map_err(|refusal| anyhow::anyhow!("instance {:?} is not a slug: {refusal:?}", instance.0))?; + Ok(root.join(&instance.0).join("control.cc")) +} + +/// The brake: released means the operator allows her to act. +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache(type = "eureka.control.brake.v1", schema = "ControlBrake")] +pub struct Brake { + #[cultcache(key = 0)] + pub released: bool, + #[cultcache(key = 1)] + pub set_at: DateTime, + #[cultcache(key = 2)] + pub set_by: String, +} + +/// The burn-rate dial: `heat` scales how active she is; the base values say +/// what heat 1.0 means. Cadence and run cap are derived from it, never stored. +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache(type = "eureka.control.burn_rate.v1", schema = "ControlBurnRate")] +pub struct BurnRate { + #[cultcache(key = 0)] + pub heat: Decimal, + #[cultcache(key = 1)] + pub base_cooldown_s: u32, + #[cultcache(key = 2)] + pub base_run_usd: Decimal, + #[cultcache(key = 3)] + pub set_at: DateTime, + #[cultcache(key = 4)] + pub set_by: String, +} + +/// Heat's inclusive bounds. +pub fn heat_min() -> Decimal { + Decimal::new(5, 2) +} +pub fn heat_max() -> Decimal { + Decimal::new(20, 1) +} + +impl BurnRate { + /// The one bounds rule: the reader holds on it and the CLI refuses on it. + pub fn in_bounds(&self) -> bool { + self.heat >= heat_min() && self.heat <= heat_max() && self.base_cooldown_s > 0 && self.base_run_usd > Decimal::ZERO + } + + /// Wake cadence: `base_cooldown_s / heat`, to the nanosecond. + fn cadence(&self) -> Option { + let seconds = Decimal::from(self.base_cooldown_s).checked_div(self.heat)?; + let whole = seconds.trunc(); + let nanos = ((seconds - whole) * Decimal::from(1_000_000_000u32)).round(); + Some(Duration::new(whole.to_u64()?, nanos.to_u32()?)) + } + + /// Per-run cap: `base_run_usd x heat`. + fn run_cap_usd(&self) -> Option { + self.base_run_usd.checked_mul(self.heat) + } +} + +/// Why the reader said held. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum HeldReason { + BrakeAbsent, + BrakeHeld, + DialAbsent, + DialOutOfBounds, + Undecodable, +} + +/// What the operator's store allows right now. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Effective { + Held { reason: HeldReason }, + Released { cadence: Duration, run_cap_usd: Decimal }, +} + +/// The store's contents, decoded and not yet judged. +#[derive(Clone, Debug, PartialEq, Eq, Default)] +pub struct ControlState { + pub brake: Option, + pub dial: Option, +} + +impl ControlState { + /// The one derivation. Order: brake first, then dial; each failing rung is + /// its own reason. + pub fn effective(&self) -> Effective { + let held = |reason| Effective::Held { reason }; + let Some(brake) = &self.brake else { return held(HeldReason::BrakeAbsent) }; + if !brake.released { + return held(HeldReason::BrakeHeld); + } + let Some(dial) = &self.dial else { return held(HeldReason::DialAbsent) }; + if !dial.in_bounds() { + return held(HeldReason::DialOutOfBounds); + } + match (dial.cadence(), dial.run_cap_usd()) { + (Some(cadence), Some(run_cap_usd)) => Effective::Released { cadence, run_cap_usd }, + _ => held(HeldReason::DialOutOfBounds), + } + } +} + +/// The reader's port: the store's envelopes as one snapshot. An absent store is +/// an empty snapshot; an unreadable one is an error. +pub trait ControlSource { + fn snapshot(&self) -> Result>; +} + +/// The real source: the control file, read without locks and without writes. +pub struct FileSource { + path: PathBuf, +} + +impl FileSource { + pub fn new(path: impl Into) -> Self { + Self { path: path.into() } + } +} + +impl ControlSource for FileSource { + fn snapshot(&self) -> Result> { + SingleFileMessagePackBackingStore::new(&self.path).pull_all_read_only_snapshot() + } +} + +fn registered_cache() -> Result { + let mut cache = CultCache::new(); + cache.register_entry_type::()?; + cache.register_entry_type::()?; + Ok(cache) +} + +/// Decodes the store into its two documents. Any envelope the cache does not +/// know, or any payload that does not decode, is an error: a store the reader +/// cannot fully read is not one it may act on. +pub fn load_state(source: &impl ControlSource) -> Result { + let mut cache = registered_cache()?; + for envelope in source.snapshot().context("control store cannot be read")? { + cache.put_raw_envelope(envelope)?; + } + Ok(ControlState { brake: cache.get::(BRAKE_KEY)?, dial: cache.get::(DIAL_KEY)? }) +} + +/// The only reader every organ uses (the waker now; the permit issuer and the +/// Persona organ later). +pub fn read_effective(source: &impl ControlSource) -> Effective { + match load_state(source) { + Ok(state) => state.effective(), + Err(_) => Effective::Held { reason: HeldReason::Undecodable }, + } +} + +/// The operator's writer over one control file. Each setter replaces its +/// document whole; the other document is untouched. The file is created on the +/// first write. +pub struct ControlWriter { + cache: CultCache, +} + +impl ControlWriter { + pub fn open(path: impl Into) -> Result { + let mut cache = registered_cache()?; + cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(path))?; + cache.pull_all_backing_stores()?; + Ok(Self { cache }) + } + + pub fn set_brake(&mut self, released: bool, set_at: DateTime, set_by: &str) -> Result<()> { + let brake = Brake { released, set_at, set_by: set_by.to_string() }; + self.cache.put(BRAKE_KEY, &brake)?; + Ok(()) + } + + /// Refuses an out-of-bounds dial before anything is written, so the store + /// never holds one that the CLI put there. + pub fn set_dial(&mut self, dial: BurnRate) -> Result<()> { + if !dial.in_bounds() { + anyhow::bail!( + "dial refused: heat must be within {}..={}, base_cooldown_s above 0 and base_run_usd above 0", + heat_min(), + heat_max() + ); + } + self.cache.put(DIAL_KEY, &dial)?; + Ok(()) + } +} diff --git a/crates/mind-body/src/lib.rs b/crates/mind-body/src/lib.rs new file mode 100644 index 0000000..9db5ac4 --- /dev/null +++ b/crates/mind-body/src/lib.rs @@ -0,0 +1,9 @@ +//! `mind-body`: the organs of one Mind's body that live in Huginn's workspace. +//! +//! Today that is `control`, the operator's brake and burn-rate dial, and `cli`, +//! the operator's hand on it. The brake and the dial are the operator's consent +//! (ruling `eureka-body:ruling:brake-dial-root-store-now`), not her body: the +//! store is root-owned and no organ of hers writes it. + +pub mod cli; +pub mod control; diff --git a/crates/mind-body/src/main.rs b/crates/mind-body/src/main.rs new file mode 100644 index 0000000..f8e771f --- /dev/null +++ b/crates/mind-body/src/main.rs @@ -0,0 +1,21 @@ +//! `mind-control`: the operator's hand on a Mind's brake and burn-rate dial. +//! All behaviour is `mind_body::cli`; this reads the process's environment and +//! clock, the two things the library never reads. + +use std::path::Path; + +fn main() { + let args: Vec = std::env::args().skip(1).collect(); + let set_by = mind_body::cli::operator_name(std::env::var("SUDO_USER").ok(), std::env::var("USER").ok()); + let result = mind_body::cli::run( + &args, + Path::new(mind_body::control::CONTROL_ROOT), + &set_by, + chrono::Utc::now(), + &mut std::io::stdout(), + ); + if let Err(error) = result { + eprintln!("mind-control: {error:#}"); + std::process::exit(2); + } +} diff --git a/crates/mind-body/tests/cli.rs b/crates/mind-body/tests/cli.rs new file mode 100644 index 0000000..3e175d2 --- /dev/null +++ b/crates/mind-body/tests/cli.rs @@ -0,0 +1,101 @@ +//! The `mind-control` command driven end to end through `mind_body::cli::run` +//! against a temporary root, and the binary once as a process for its exit +//! status. Nothing touches `/etc`. + +use chrono::{TimeZone, Utc}; +use mind_body::cli::{operator_name, run}; + +fn go(root: &std::path::Path, args: &[&str], set_by: &str, secs: i64) -> (anyhow::Result<()>, String) { + let args: Vec = args.iter().map(|a| a.to_string()).collect(); + let mut out = Vec::new(); + let now = Utc.timestamp_opt(1_800_000_000 + secs, 0).unwrap(); + let result = run(&args, root, set_by, now, &mut out); + (result, String::from_utf8(out).unwrap()) +} + +#[test] +fn show_reports_every_rung_from_an_absent_store_to_released() { + let root = tempfile::tempdir().unwrap(); + let (r, out) = go(root.path(), &["--instance", "eureka", "show"], "op", 0); + r.unwrap(); + assert!(out.contains("brake: absent") && out.contains("dial: absent") && out.contains("BrakeAbsent"), "{out}"); + + go(root.path(), &["--instance", "eureka", "brake", "release"], "alice", 1).0.unwrap(); + let (_, out) = go(root.path(), &["--instance", "eureka", "show"], "op", 2); + assert!(out.contains("brake: released") && out.contains("set_by alice") && out.contains("DialAbsent"), "{out}"); + + go(root.path(), &["--instance", "eureka", "dial", "set", "--heat", "0.5", "--base-cooldown-s", "3600", "--base-run-usd", "5"], "bob", 3) + .0 + .unwrap(); + let (_, out) = go(root.path(), &["--instance", "eureka", "show"], "op", 4); + assert!(out.contains("heat 0.5") && out.contains("base_cooldown_s 3600") && out.contains("set_by bob"), "{out}"); + assert!(out.contains("Released") && out.contains("7200s"), "{out}"); + + go(root.path(), &["--instance", "eureka", "brake", "hold"], "alice", 5).0.unwrap(); + let (_, out) = go(root.path(), &["--instance", "eureka", "show"], "op", 6); + assert!(out.contains("brake: held") && out.contains("BrakeHeld"), "{out}"); +} + +#[test] +fn instances_do_not_share_a_store() { + let root = tempfile::tempdir().unwrap(); + go(root.path(), &["--instance", "one", "brake", "release"], "op", 0).0.unwrap(); + let (_, out) = go(root.path(), &["--instance", "two", "show"], "op", 1); + assert!(out.contains("brake: absent"), "{out}"); + assert!(root.path().join("one").join("control.cc").exists()); +} + +#[test] +fn a_dial_set_outside_bounds_refuses_and_leaves_the_store_unchanged() { + let root = tempfile::tempdir().unwrap(); + go(root.path(), &["--instance", "eureka", "brake", "release"], "op", 0).0.unwrap(); + go(root.path(), &["--instance", "eureka", "dial", "set", "--heat", "1", "--base-cooldown-s", "60", "--base-run-usd", "1"], "op", 1) + .0 + .unwrap(); + let path = root.path().join("eureka").join("control.cc"); + let before = std::fs::read(&path).unwrap(); + for heat in ["2.0001", "0.0499", "0", "-1"] { + let (r, _) = go(root.path(), &["--instance", "eureka", "dial", "set", "--heat", heat, "--base-cooldown-s", "60", "--base-run-usd", "1"], "op", 9); + assert!(r.is_err(), "heat {heat} must be refused"); + assert_eq!(std::fs::read(&path).unwrap(), before, "heat {heat} changed the store"); + } + + // A refusal on a store that does not exist yet must not create it. + let fresh = tempfile::tempdir().unwrap(); + let (r, _) = go(fresh.path(), &["--instance", "eureka", "dial", "set", "--heat", "5", "--base-cooldown-s", "60", "--base-run-usd", "1"], "op", 0); + assert!(r.is_err()); + assert!(!fresh.path().join("eureka").join("control.cc").exists()); +} + +#[test] +fn malformed_commands_are_refused_and_write_nothing() { + let root = tempfile::tempdir().unwrap(); + let bad: &[&[&str]] = &[ + &[], + &["show"], + &["--instance", "eureka"], + &["--instance", "eureka", "brake"], + &["--instance", "eureka", "brake", "toggle"], + &["--instance", "eureka", "dial", "set", "--heat", "1"], + &["--instance", "eureka", "dial", "set", "--heat", "1", "--heat", "1", "--base-cooldown-s", "60", "--base-run-usd", "1"], + &["--instance", "eureka", "dial", "set", "--heat", "x", "--base-cooldown-s", "60", "--base-run-usd", "1"], + &["--instance", "eureka", "dial", "set", "--heat", "1", "--base-cooldown-s", "1.5", "--base-run-usd", "1"], + &["--instance", "eureka", "dial", "set", "--heat", "1", "--base-cooldown-s", "60", "--base-run-usd", "1", "--extra", "1"], + &["--instance", "eureka", "dial", "set", "--heat"], + &["--instance", "../evil", "brake", "release"], + ]; + for args in bad { + assert!(go(root.path(), args, "op", 0).0.is_err(), "{args:?}"); + } + assert_eq!(std::fs::read_dir(root.path()).unwrap().count(), 0, "a refused command wrote something"); +} + +#[test] +fn the_operator_is_sudo_user_then_user_then_unknown() { + let s = |v: &str| Some(v.to_string()); + assert_eq!(operator_name(s("alice"), s("root")), "alice"); + assert_eq!(operator_name(None, s("bob")), "bob"); + assert_eq!(operator_name(s(""), s("bob")), "bob"); + assert_eq!(operator_name(None, s("")), "unknown"); + assert_eq!(operator_name(None, None), "unknown"); +} diff --git a/crates/mind-body/tests/control.rs b/crates/mind-body/tests/control.rs new file mode 100644 index 0000000..f6d4a1d --- /dev/null +++ b/crates/mind-body/tests/control.rs @@ -0,0 +1,295 @@ +//! Behavioural tests for the brake, the dial and the one reader. Every test +//! works in a temporary directory through the same file door the units use; +//! nothing touches `/etc`. +//! +//! The permission boundary (no user she runs as can write the file) belongs to +//! the filesystem, not to this crate, and is not tested here: the verify +//! container runs as root, where a permission failure cannot be provoked. + +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use anyhow::{Result, anyhow}; +use chrono::{DateTime, TimeZone, Utc}; +use cultcache_rs::{CultCache, CultCacheEnvelope, DatabaseEntry, SingleFileMessagePackBackingStore}; +use mind_body::control::{ + BRAKE_KEY, BurnRate, ControlSource, ControlWriter, Effective, FileSource, HeldReason, control_path, load_state, read_effective, +}; +use rust_decimal::Decimal; +use std::str::FromStr; + +fn dec(text: &str) -> Decimal { + Decimal::from_str(text).unwrap() +} + +fn at(seconds: i64) -> DateTime { + Utc.timestamp_opt(1_800_000_000 + seconds, 0).unwrap() +} + +fn dial(heat: &str, cooldown: u32, run_usd: &str) -> BurnRate { + BurnRate { heat: dec(heat), base_cooldown_s: cooldown, base_run_usd: dec(run_usd), set_at: at(0), set_by: "op".into() } +} + +fn store_path(dir: &Path) -> PathBuf { + dir.join("control.cc") +} + +fn held(reason: HeldReason) -> Effective { + Effective::Held { reason } +} + +/// A written store: brake released and the given dial, through the real writer. +fn released_store(dir: &Path, dial: BurnRate) -> PathBuf { + let path = store_path(dir); + let mut writer = ControlWriter::open(&path).unwrap(); + writer.set_brake(true, at(0), "op").unwrap(); + writer.set_dial(dial).unwrap(); + path +} + +fn effective(path: &Path) -> Effective { + read_effective(&FileSource::new(path)) +} + +/// A dial written around the writer's bounds check, so the reader's own check +/// is what is under test. +fn write_unchecked_dial(path: &Path, dial: &BurnRate) { + let mut cache = CultCache::new(); + cache.register_entry_type::().unwrap(); + cache.register_entry_type::().unwrap(); + cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(path)).unwrap(); + cache.pull_all_backing_stores().unwrap(); + cache.put(mind_body::control::DIAL_KEY, dial).unwrap(); +} + +#[test] +fn an_absent_store_reads_as_held() { + let dir = tempfile::tempdir().unwrap(); + assert_eq!(effective(&store_path(dir.path())), held(HeldReason::BrakeAbsent)); + assert!(!store_path(dir.path()).exists(), "reading must not create the store"); +} + +#[test] +fn an_absent_brake_reads_as_held_even_with_a_dial() { + let dir = tempfile::tempdir().unwrap(); + let path = store_path(dir.path()); + ControlWriter::open(&path).unwrap().set_dial(dial("1", 3600, "1")).unwrap(); + assert_eq!(effective(&path), held(HeldReason::BrakeAbsent)); +} + +#[test] +fn an_undecodable_store_reads_as_held() { + let dir = tempfile::tempdir().unwrap(); + let path = released_store(dir.path(), dial("1", 3600, "1")); + let good = std::fs::read(&path).unwrap(); + + // Garbage in place of the store. + std::fs::write(&path, b"this is not a cultcache store").unwrap(); + assert_eq!(effective(&path), held(HeldReason::Undecodable)); + + // A store cut off mid-document. + std::fs::write(&path, &good[..good.len() / 2]).unwrap(); + assert_eq!(effective(&path), held(HeldReason::Undecodable)); + + // The untouched store is still released, so the two reads above were the damage. + std::fs::write(&path, &good).unwrap(); + assert!(matches!(effective(&path), Effective::Released { .. })); +} + +#[test] +fn a_path_that_cannot_be_read_as_a_file_reads_as_held() { + let dir = tempfile::tempdir().unwrap(); + // A directory sits where the store should be: it exists and cannot be read as a file. + let path = store_path(dir.path()); + std::fs::create_dir(&path).unwrap(); + assert_eq!(effective(&path), held(HeldReason::Undecodable)); +} + +#[test] +fn a_source_that_fails_reads_as_held() { + struct Failing; + impl ControlSource for Failing { + fn snapshot(&self) -> Result> { + Err(anyhow!("disk gone")) + } + } + assert_eq!(read_effective(&Failing), held(HeldReason::Undecodable)); +} + +#[test] +fn an_unknown_document_type_makes_the_store_undecodable() { + #[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] + #[cultcache(type = "eureka.control.something_else.v1", schema = "Other")] + struct Other { + #[cultcache(key = 0)] + value: u32, + } + let dir = tempfile::tempdir().unwrap(); + let path = released_store(dir.path(), dial("1", 3600, "1")); + let mut cache = CultCache::new(); + cache.register_entry_type::().unwrap(); + cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path)).unwrap(); + cache.put("x", &Other { value: 1 }).unwrap(); + // The store now holds a released brake, a good dial and a foreign document. + assert_eq!(effective(&path), held(HeldReason::Undecodable)); +} + +#[test] +fn a_brake_payload_that_does_not_decode_makes_the_store_undecodable() { + // Same type id as the brake, a different shape: the payload cannot decode as a Brake. + #[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] + #[cultcache(type = "eureka.control.brake.v1", schema = "ControlBrake")] + struct WrongBrake { + #[cultcache(key = 0)] + released: String, + } + let dir = tempfile::tempdir().unwrap(); + let path = store_path(dir.path()); + let mut cache = CultCache::new(); + cache.register_entry_type::().unwrap(); + cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path)).unwrap(); + cache.put(BRAKE_KEY, &WrongBrake { released: "yes".into() }).unwrap(); + assert_eq!(effective(&path), held(HeldReason::Undecodable)); +} + +#[test] +fn a_decode_failure_outranks_a_held_brake() { + let dir = tempfile::tempdir().unwrap(); + let path = released_store(dir.path(), dial("1", 3600, "1")); + ControlWriter::open(&path).unwrap().set_brake(false, at(5), "op").unwrap(); + assert_eq!(effective(&path), held(HeldReason::BrakeHeld)); + std::fs::write(&path, b"junk").unwrap(); + assert_eq!(effective(&path), held(HeldReason::Undecodable)); +} + +#[test] +fn a_released_brake_with_no_dial_reads_held_dial_absent() { + let dir = tempfile::tempdir().unwrap(); + let path = store_path(dir.path()); + ControlWriter::open(&path).unwrap().set_brake(true, at(0), "op").unwrap(); + assert_eq!(effective(&path), held(HeldReason::DialAbsent)); +} + +#[test] +fn dial_bounds_are_inclusive_at_both_ends() { + for (heat, expected_in) in [("0.05", true), ("2.0", true), ("0.0499", false), ("2.0001", false), ("0", false), ("-1", false), ("3", false)] { + let dir = tempfile::tempdir().unwrap(); + let path = released_store_unchecked(dir.path(), dial(heat, 3600, "1")); + let got = effective(&path); + if expected_in { + assert!(matches!(got, Effective::Released { .. }), "heat {heat}: {got:?}"); + } else { + assert_eq!(got, held(HeldReason::DialOutOfBounds), "heat {heat}"); + } + } +} + +#[test] +fn a_zero_base_cooldown_or_a_non_positive_run_cost_is_out_of_bounds() { + for bad in [dial("1", 0, "1"), dial("1", 3600, "0"), dial("1", 3600, "-0.5")] { + let dir = tempfile::tempdir().unwrap(); + let path = released_store_unchecked(dir.path(), bad.clone()); + assert_eq!(effective(&path), held(HeldReason::DialOutOfBounds), "{bad:?}"); + } + let dir = tempfile::tempdir().unwrap(); + let smallest = released_store_unchecked(dir.path(), dial("1", 1, "0.000001")); + assert!(matches!(effective(&smallest), Effective::Released { .. })); +} + +fn released_store_unchecked(dir: &Path, bad: BurnRate) -> PathBuf { + let path = store_path(dir); + ControlWriter::open(&path).unwrap().set_brake(true, at(0), "op").unwrap(); + write_unchecked_dial(&path, &bad); + path +} + +#[test] +fn cadence_and_run_cap_are_the_dial_divided_and_multiplied_by_heat() { + for (heat, cooldown, run_usd, cadence, cap) in [ + ("0.5", 3600u32, "5", Duration::from_secs(7200), dec("2.5")), + ("2.0", 3600, "5", Duration::from_secs(1800), dec("10.0")), + ("1", 600, "3", Duration::from_secs(600), dec("3")), + ("0.05", 90, "4", Duration::from_secs(1800), dec("0.20")), + ("1.5", 10, "2", Duration::new(6, 666_666_667), dec("3.0")), + ("2.0", 1, "0.25", Duration::from_millis(500), dec("0.500")), + ] { + let dir = tempfile::tempdir().unwrap(); + let path = released_store(dir.path(), dial(heat, cooldown, run_usd)); + assert_eq!(effective(&path), Effective::Released { cadence, run_cap_usd: cap }, "heat {heat} cooldown {cooldown} run {run_usd}"); + } +} + +#[test] +fn the_operators_last_write_is_what_is_in_force_and_provenance_round_trips() { + let dir = tempfile::tempdir().unwrap(); + let path = released_store(dir.path(), dial("1", 3600, "1")); + assert!(matches!(effective(&path), Effective::Released { .. })); + + ControlWriter::open(&path).unwrap().set_brake(false, at(60), "alice").unwrap(); + assert_eq!(effective(&path), held(HeldReason::BrakeHeld)); + let state = load_state(&FileSource::new(&path)).unwrap(); + let brake = state.brake.unwrap(); + assert_eq!((brake.released, brake.set_at, brake.set_by.as_str()), (false, at(60), "alice")); + // The dial survives the brake write untouched. + assert_eq!(state.dial.unwrap().set_by, "op"); + + ControlWriter::open(&path).unwrap().set_brake(true, at(120), "bob").unwrap(); + assert!(matches!(effective(&path), Effective::Released { .. })); + let brake = load_state(&FileSource::new(&path)).unwrap().brake.unwrap(); + assert_eq!((brake.released, brake.set_at, brake.set_by.as_str()), (true, at(120), "bob")); +} + +#[test] +fn setting_the_dial_replaces_it_whole_and_keeps_the_brake() { + let dir = tempfile::tempdir().unwrap(); + let path = released_store(dir.path(), dial("1", 3600, "1")); + let mut next = dial("0.5", 100, "8"); + next.set_at = at(30); + next.set_by = "carol".into(); + ControlWriter::open(&path).unwrap().set_dial(next.clone()).unwrap(); + let state = load_state(&FileSource::new(&path)).unwrap(); + assert_eq!(state.dial, Some(next)); + assert!(state.brake.unwrap().released); +} + +#[test] +fn the_writer_refuses_an_out_of_bounds_dial_and_leaves_the_store_unchanged() { + let dir = tempfile::tempdir().unwrap(); + let path = released_store(dir.path(), dial("1", 3600, "1")); + let before = std::fs::read(&path).unwrap(); + for bad in [dial("2.0001", 3600, "1"), dial("0.0499", 3600, "1"), dial("1", 0, "1"), dial("1", 3600, "0")] { + assert!(ControlWriter::open(&path).unwrap().set_dial(bad).is_err()); + assert_eq!(std::fs::read(&path).unwrap(), before); + } +} + +#[test] +fn reading_writes_nothing_and_takes_no_lock_file() { + let dir = tempfile::tempdir().unwrap(); + let path = released_store(dir.path(), dial("1", 3600, "1")); + let listing = |dir: &Path| { + let mut names: Vec<_> = std::fs::read_dir(dir).unwrap().map(|e| e.unwrap().file_name()).collect(); + names.sort(); + names + }; + // The writer left its sibling lock file; remove it, so a reader that wants one has to create it. + for entry in std::fs::read_dir(dir.path()).unwrap() { + let entry = entry.unwrap().path(); + if entry != path { + std::fs::remove_file(entry).unwrap(); + } + } + let before = (listing(dir.path()), std::fs::read(&path).unwrap()); + assert!(matches!(effective(&path), Effective::Released { .. })); + assert_eq!((listing(dir.path()), std::fs::read(&path).unwrap()), before); +} + +#[test] +fn the_control_path_is_derived_from_a_valid_instance_only() { + use eureka_pipeline::Slug; + let root = Path::new("/etc/gamecult/minds"); + assert_eq!(control_path(root, &Slug("eureka".into())).unwrap(), root.join("eureka").join("control.cc")); + for bad in ["", "../etc", "a/b", "A b", "eureka/.."] { + assert!(control_path(root, &Slug(bad.into())).is_err(), "{bad:?}"); + } +}