From 4b3e850fefca8308a2c5ec9a5f6cc0da86439401 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 9 Oct 2026 15:58:29 +0200 Subject: [PATCH 01/14] 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:?}"); + } +} From 76367e888ac60e53603f38a4dbefb296513e82e3 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 9 Oct 2026 16:01:14 +0200 Subject: [PATCH 02/14] mind-body: need() as a fn so the borrowed value outlives the closure Co-Authored-By: Claude Opus 5.5 --- crates/mind-body/src/cli.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/mind-body/src/cli.rs b/crates/mind-body/src/cli.rs index 5c4e013..e9b543e 100644 --- a/crates/mind-body/src/cli.rs +++ b/crates/mind-body/src/cli.rs @@ -70,7 +70,6 @@ fn parse_dial(flags: &[&str], now: DateTime, set_by: &str) -> Result, 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")? @@ -82,6 +81,10 @@ fn parse_dial(flags: &[&str], now: DateTime, set_by: &str) -> Result(value: Option<&'a str>, name: &str) -> Result<&'a str> { + value.ok_or_else(|| anyhow!("{name} is required")) +} + fn show(path: &Path, instance: &Slug, out: &mut impl Write) -> Result<()> { let source = FileSource::new(path); writeln!(out, "instance: {}", instance.0)?; From 9a9165d4f1d14720700b73c05897eb3881fbe056 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 9 Oct 2026 16:05:51 +0200 Subject: [PATCH 03/14] mind-body: pin the --instance guard and the binary's refusal exit Two cargo-mutants survivors from the first run (main -> (), and the --instance match guard -> true) are killed by a malformed-command table row and a process-level test of the refused invocation. Co-Authored-By: Claude Opus 5.5 --- crates/mind-body/tests/cli.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/crates/mind-body/tests/cli.rs b/crates/mind-body/tests/cli.rs index 3e175d2..1c01625 100644 --- a/crates/mind-body/tests/cli.rs +++ b/crates/mind-body/tests/cli.rs @@ -83,6 +83,8 @@ fn malformed_commands_are_refused_and_write_nothing() { &["--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"], + &["--instances", "eureka", "brake", "release"], + &["eureka", "eureka", "brake", "release"], ]; for args in bad { assert!(go(root.path(), args, "op", 0).0.is_err(), "{args:?}"); @@ -99,3 +101,12 @@ fn the_operator_is_sudo_user_then_user_then_unknown() { assert_eq!(operator_name(None, s("")), "unknown"); assert_eq!(operator_name(None, None), "unknown"); } + +#[test] +fn the_binary_exits_nonzero_with_the_usage_when_refused() { + // A refusal before any path is derived, so this never reaches /etc. + let output = std::process::Command::new(env!("CARGO_BIN_EXE_mind-control")).output().unwrap(); + assert_eq!(output.status.code(), Some(2)); + assert!(String::from_utf8_lossy(&output.stderr).contains("usage: mind-control")); + assert!(output.stdout.is_empty()); +} From 0a3b9c3ba5e3a7f875c7732ba325c92771dfdc07 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 9 Oct 2026 16:24:58 +0200 Subject: [PATCH 04/14] mind-body: the writer creates the control store 0755 and 0644 whatever the umask Soul s1: created under umask 000 the directory was 777 and the files 666, so a non-root user could write the store. The writer now sets the directory (when it creates it), the lock file and the store file explicitly, after the setter's own refusals so a refused write still creates nothing. CultCache stages with the process umask and offers no mode, so the store file is set after the rename. Co-Authored-By: Claude Opus 5.5 --- crates/mind-body/src/control.rs | 61 ++++++++++++++++++++++++++++++--- 1 file changed, 57 insertions(+), 4 deletions(-) diff --git a/crates/mind-body/src/control.rs b/crates/mind-body/src/control.rs index 3806d98..d0b0282 100644 --- a/crates/mind-body/src/control.rs +++ b/crates/mind-body/src/control.rs @@ -199,20 +199,72 @@ pub fn read_effective(source: &impl ControlSource) -> Effective { /// first write. pub struct ControlWriter { cache: CultCache, + path: PathBuf, +} + +/// The modes the store is created with, whatever the operator's umask: the +/// directory and the files under it are world-readable and writable by their +/// owner alone, which is what makes the filesystem the write authority. +const DIR_MODE: u32 = 0o755; +const FILE_MODE: u32 = 0o644; + +#[cfg(unix)] +fn set_mode(path: &Path, mode: u32) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)) + .with_context(|| format!("failed to set the mode of {}", path.display())) +} + +#[cfg(not(unix))] +fn set_mode(_path: &Path, _mode: u32) -> Result<()> { + Ok(()) } impl ControlWriter { pub fn open(path: impl Into) -> Result { + let path = path.into(); let mut cache = registered_cache()?; - cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(path))?; + cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path))?; cache.pull_all_backing_stores()?; - Ok(Self { cache }) + Ok(Self { cache, path }) + } + + /// Before the first write: the control directory exists at 0755 and the + /// lock file at 0644, set explicitly because creation takes the umask. + /// Called after a setter's own refusals, so a refused write creates nothing. + fn prepare(&self) -> Result<()> { + let directory = self.path.parent().context("control path has no directory")?; + if !directory.exists() { + std::fs::create_dir_all(directory).with_context(|| format!("failed to create {}", directory.display()))?; + set_mode(directory, DIR_MODE)?; + } + let mut lock_name = self.path.file_name().context("control path has no file name")?.to_os_string(); + lock_name.push(".lock"); + let lock = self.path.with_file_name(lock_name); + if !lock.exists() { + std::fs::OpenOptions::new() + .create(true) + .write(true) + .truncate(false) + .open(&lock) + .with_context(|| format!("failed to open {}", lock.display()))?; + set_mode(&lock, FILE_MODE)?; + } + Ok(()) + } + + /// After a write: the store file the atomic rename put in place takes the + /// umask-free mode. CultCache stages with the process umask and offers no + /// way to pass a mode, so this is where the mode can be set. + fn settle(&self) -> Result<()> { + set_mode(&self.path, FILE_MODE) } 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.prepare()?; self.cache.put(BRAKE_KEY, &brake)?; - Ok(()) + self.settle() } /// Refuses an out-of-bounds dial before anything is written, so the store @@ -225,7 +277,8 @@ impl ControlWriter { heat_max() ); } + self.prepare()?; self.cache.put(DIAL_KEY, &dial)?; - Ok(()) + self.settle() } } From 550037e0b3a13ac2a2301fc03d84fe53d948e790 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 9 Oct 2026 16:24:58 +0200 Subject: [PATCH 05/14] mind-body: pin the creation modes, the non-root refusal and the dial decode branch Modes under umask 000/022/077 via a re-executed test child; a non-root user (setpriv uid 65534, skipped with a message when not root) cannot write the store but can read it; an undecodable or NaN dial reads Held(Undecodable) whatever the brake says, which kills the fail-open default-dial mutant. Co-Authored-By: Claude Opus 5.5 --- crates/mind-body/tests/control.rs | 121 ++++++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) diff --git a/crates/mind-body/tests/control.rs b/crates/mind-body/tests/control.rs index f6d4a1d..2293afc 100644 --- a/crates/mind-body/tests/control.rs +++ b/crates/mind-body/tests/control.rs @@ -293,3 +293,124 @@ fn the_control_path_is_derived_from_a_valid_instance_only() { assert!(control_path(root, &Slug(bad.into())).is_err(), "{bad:?}"); } } + +/// A dial document under the dial's type id whose payload is not a dial. +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache(type = "eureka.control.burn_rate.v1", schema = "ControlBurnRate")] +struct WrongDial { + #[cultcache(key = 0)] + heat: String, +} + +fn write_wrong_dial(path: &Path, heat: &str) { + let mut cache = CultCache::new(); + 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, &WrongDial { heat: heat.into() }).unwrap(); +} + +#[test] +fn a_dial_that_does_not_decode_reads_held_whatever_the_brake_says() { + for brake_released in [true, false] { + for heat in ["not a number", "NaN", ""] { + let dir = tempfile::tempdir().unwrap(); + let path = store_path(dir.path()); + ControlWriter::open(&path).unwrap().set_brake(brake_released, at(0), "op").unwrap(); + write_wrong_dial(&path, heat); + assert_eq!( + effective(&path), + held(HeldReason::Undecodable), + "brake released={brake_released}, dial heat {heat:?}: a dial that cannot be read is never replaced by a default" + ); + } + } +} + +#[cfg(unix)] +mod modes { + use super::*; + use std::os::unix::fs::PermissionsExt; + use std::process::Command; + + const CHILD: &str = "MIND_BODY_MODE_CHILD"; + const ROOT: &str = "MIND_BODY_MODE_ROOT"; + + fn mode(path: &Path) -> u32 { + std::fs::metadata(path).unwrap().permissions().mode() & 0o777 + } + + /// The body of the two re-executed children; with no CHILD set it does + /// nothing, so a plain run of the suite passes it vacuously. + #[test] + fn mode_child() { + let Ok(role) = std::env::var(CHILD) else { return }; + let path = Path::new(&std::env::var(ROOT).unwrap()).join("eureka").join("control.cc"); + match role.as_str() { + "write" => { + let mut writer = ControlWriter::open(&path).unwrap(); + writer.set_brake(true, at(0), "op").unwrap(); + writer.set_dial(dial("1", 3600, "1")).unwrap(); + } + "intruder" => { + assert!(ControlWriter::open(&path).and_then(|mut writer| writer.set_brake(false, at(1), "intruder")).is_err()); + assert!(ControlWriter::open(Path::new(&std::env::var(ROOT).unwrap()).join("ghost").join("control.cc")) + .and_then(|mut writer| writer.set_brake(true, at(1), "intruder")) + .is_err()); + assert!(std::fs::OpenOptions::new().write(true).open(&path).is_err()); + assert!(std::fs::OpenOptions::new().write(true).open(path.with_file_name("control.cc.lock")).is_err()); + assert!(matches!(effective(&path), Effective::Released { .. }), "a non-root user can still read the store"); + } + other => panic!("unknown role {other}"), + } + } + + fn spawn_child(role: &str, root: &Path, exe: &Path, prefix: &[&str], umask: &str) -> std::process::ExitStatus { + let script = format!("umask {umask}; exec \"$@\" --exact modes::mode_child --nocapture"); + let mut command = Command::new(prefix.first().copied().unwrap_or("sh")); + if prefix.is_empty() { + command.args(["-c", &script, "sh"]).arg(exe); + } else { + command.args(&prefix[1..]).args(["sh", "-c", &script, "sh"]).arg(exe); + } + command.env(CHILD, role).env(ROOT, root).status().unwrap() + } + + fn written_under(umask: &str) -> (tempfile::TempDir, PathBuf) { + let dir = tempfile::tempdir().unwrap(); + std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o755)).unwrap(); + let exe = std::env::current_exe().unwrap(); + assert!(spawn_child("write", dir.path(), &exe, &[], umask).success()); + let control = dir.path().join("eureka"); + (dir, control) + } + + #[test] + fn the_store_is_created_0755_and_0644_whatever_the_operators_umask() { + for umask in ["000", "022", "077"] { + let (_dir, control) = written_under(umask); + assert_eq!(mode(&control), 0o755, "directory under umask {umask}"); + assert_eq!(mode(&control.join("control.cc")), 0o644, "store under umask {umask}"); + assert_eq!(mode(&control.join("control.cc.lock")), 0o644, "lock under umask {umask}"); + } + } + + fn running_as_root() -> bool { + Command::new("id").arg("-u").output().map(|out| String::from_utf8_lossy(&out.stdout).trim() == "0").unwrap_or(false) + } + + #[test] + fn a_non_root_user_cannot_write_the_store_but_can_read_it() { + if !running_as_root() || Command::new("setpriv").arg("--version").output().is_err() { + eprintln!("SKIPPED: needs root and setpriv (the verify container has both) to become uid 65534"); + return; + } + let (dir, _control) = written_under("000"); + // A copy of the test binary the unprivileged user can execute. + let exe = dir.path().join("test-bin"); + std::fs::copy(std::env::current_exe().unwrap(), &exe).unwrap(); + std::fs::set_permissions(&exe, std::fs::Permissions::from_mode(0o755)).unwrap(); + let status = spawn_child("intruder", dir.path(), &exe, &["setpriv", "--reuid=65534", "--regid=65534", "--clear-groups"], "022"); + assert!(status.success(), "the intruder child asserted a refusal that did not happen"); + } +} From d125b95c8d11ad85c598a460dbdcb09fc8f05cd5 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 9 Oct 2026 16:25:26 +0200 Subject: [PATCH 06/14] mind-body: the wrong-dial fixture registers the brake it reads beside Co-Authored-By: Claude Opus 5.5 --- crates/mind-body/tests/control.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/mind-body/tests/control.rs b/crates/mind-body/tests/control.rs index 2293afc..2bb513b 100644 --- a/crates/mind-body/tests/control.rs +++ b/crates/mind-body/tests/control.rs @@ -305,6 +305,7 @@ struct WrongDial { fn write_wrong_dial(path: &Path, heat: &str) { 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, &WrongDial { heat: heat.into() }).unwrap(); From 2377fa6d405211983f9e756ae634febe61025260 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 9 Oct 2026 16:28:39 +0200 Subject: [PATCH 07/14] mind-body: the mode test writes once, so a skipped lock mode is not repaired by the second write cargo-mutants on the diff missed deleting the negation before the lock check. Co-Authored-By: Claude Opus 5.5 --- crates/mind-body/tests/control.rs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/crates/mind-body/tests/control.rs b/crates/mind-body/tests/control.rs index 2bb513b..97b8101 100644 --- a/crates/mind-body/tests/control.rs +++ b/crates/mind-body/tests/control.rs @@ -353,6 +353,9 @@ mod modes { writer.set_brake(true, at(0), "op").unwrap(); writer.set_dial(dial("1", 3600, "1")).unwrap(); } + "brake" => { + ControlWriter::open(&path).unwrap().set_brake(true, at(0), "op").unwrap(); + } "intruder" => { assert!(ControlWriter::open(&path).and_then(|mut writer| writer.set_brake(false, at(1), "intruder")).is_err()); assert!(ControlWriter::open(Path::new(&std::env::var(ROOT).unwrap()).join("ghost").join("control.cc")) @@ -377,11 +380,11 @@ mod modes { command.env(CHILD, role).env(ROOT, root).status().unwrap() } - fn written_under(umask: &str) -> (tempfile::TempDir, PathBuf) { + fn written_under(role: &str, umask: &str) -> (tempfile::TempDir, PathBuf) { let dir = tempfile::tempdir().unwrap(); std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o755)).unwrap(); let exe = std::env::current_exe().unwrap(); - assert!(spawn_child("write", dir.path(), &exe, &[], umask).success()); + assert!(spawn_child(role, dir.path(), &exe, &[], umask).success()); let control = dir.path().join("eureka"); (dir, control) } @@ -389,7 +392,8 @@ mod modes { #[test] fn the_store_is_created_0755_and_0644_whatever_the_operators_umask() { for umask in ["000", "022", "077"] { - let (_dir, control) = written_under(umask); + let (_dir, control) = written_under("brake", umask); + // One write only: a second write would set what the first left. assert_eq!(mode(&control), 0o755, "directory under umask {umask}"); assert_eq!(mode(&control.join("control.cc")), 0o644, "store under umask {umask}"); assert_eq!(mode(&control.join("control.cc.lock")), 0o644, "lock under umask {umask}"); @@ -406,7 +410,7 @@ mod modes { eprintln!("SKIPPED: needs root and setpriv (the verify container has both) to become uid 65534"); return; } - let (dir, _control) = written_under("000"); + let (dir, _control) = written_under("write", "000"); // A copy of the test binary the unprivileged user can execute. let exe = dir.path().join("test-bin"); std::fs::copy(std::env::current_exe().unwrap(), &exe).unwrap(); From 5cccab689907eb7243a82e2ec25d64daebc0212a Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 9 Oct 2026 16:39:19 +0200 Subject: [PATCH 08/14] mind-body: the binary restricts its umask; the writer refuses unsafe installs Soul s2: under a permissive umask the store was group/world-writable between CultCache's rename and the chmod, and an fd opened there survived it. The mind-control binary now sets umask 077 first (the library never touches the umask). The writer refuses to write through an existing directory, lock or store that is not root's or is group/world-writable, with the typed UnsafeStore error naming path, mode and owner. Tests: a non-root open-for-write probe against the real binary under umask 000; refusals for 0777/0775 directories, 0666/0664 locks and stores, and a non-root-owned directory; accepted root installs. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + crates/mind-body/Cargo.toml | 3 + crates/mind-body/src/cli.rs | 2 +- crates/mind-body/src/control.rs | 59 ++++++++++- crates/mind-body/src/main.rs | 18 +++- crates/mind-body/tests/control.rs | 160 +++++++++++++++++++++++++++++- 6 files changed, 231 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 01c68ba..a46de8b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -832,6 +832,7 @@ dependencies = [ "chrono", "cultcache-rs", "eureka-pipeline", + "libc", "rust_decimal", "serde", "tempfile", diff --git a/crates/mind-body/Cargo.toml b/crates/mind-body/Cargo.toml index 7d43e39..eab1e63 100644 --- a/crates/mind-body/Cargo.toml +++ b/crates/mind-body/Cargo.toml @@ -22,6 +22,9 @@ eureka-pipeline = { path = "../eureka-pipeline" } rust_decimal = { version = "1", default-features = false, features = ["std", "serde"] } serde = { version = "1", features = ["derive"] } +[target.'cfg(unix)'.dependencies] +libc = "0.2" + [dev-dependencies] tempfile = "3" diff --git a/crates/mind-body/src/cli.rs b/crates/mind-body/src/cli.rs index e9b543e..e7aa841 100644 --- a/crates/mind-body/src/cli.rs +++ b/crates/mind-body/src/cli.rs @@ -1,5 +1,5 @@ //! 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 +//! control store. It checks no caller identity: the file is //! root-owned and the filesystem is the authority. //! //! ```text diff --git a/crates/mind-body/src/control.rs b/crates/mind-body/src/control.rs index d0b0282..f42dfc4 100644 --- a/crates/mind-body/src/control.rs +++ b/crates/mind-body/src/control.rs @@ -14,7 +14,9 @@ //! 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. +//! the filesystem is the authority. The writer only refuses to write through an +//! existing directory, lock or store that is not root's or that group or world +//! can write (`UnsafeStore`); it checks no caller identity. use std::path::{Path, PathBuf}; use std::time::Duration; @@ -215,6 +217,45 @@ fn set_mode(path: &Path, mode: u32) -> Result<()> { .with_context(|| format!("failed to set the mode of {}", path.display())) } +/// A control path the writer will not write through: it is not root's, or +/// group or world can write it. Names the path, its mode and its owner. +#[derive(Debug)] +pub struct UnsafeStore { + pub path: PathBuf, + pub mode: u32, + pub uid: u32, +} + +impl std::fmt::Display for UnsafeStore { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "refusing to write: {} is mode {:04o} owned by uid {}; it must be owned by root (uid 0) and not group- or world-writable", + self.path.display(), + self.mode, + self.uid + ) + } +} + +impl std::error::Error for UnsafeStore {} + +#[cfg(unix)] +fn require_root_only_writable(path: &Path) -> Result<()> { + use std::os::unix::fs::MetadataExt; + let meta = std::fs::metadata(path).with_context(|| format!("failed to inspect {}", path.display()))?; + let (mode, uid) = (meta.mode() & 0o7777, meta.uid()); + if uid != 0 || mode & 0o022 != 0 { + return Err(UnsafeStore { path: path.to_path_buf(), mode, uid }.into()); + } + Ok(()) +} + +#[cfg(not(unix))] +fn require_root_only_writable(_path: &Path) -> Result<()> { + Ok(()) +} + #[cfg(not(unix))] fn set_mode(_path: &Path, _mode: u32) -> Result<()> { Ok(()) @@ -231,16 +272,24 @@ impl ControlWriter { /// Before the first write: the control directory exists at 0755 and the /// lock file at 0644, set explicitly because creation takes the umask. - /// Called after a setter's own refusals, so a refused write creates nothing. + /// A directory, lock or store that already exists must be root's and not + /// group- or world-writable, else the write is refused (`UnsafeStore`): the + /// operator fixes the install. Called after a setter's own refusals, so a + /// refused write creates nothing. fn prepare(&self) -> Result<()> { let directory = self.path.parent().context("control path has no directory")?; + let mut lock_name = self.path.file_name().context("control path has no file name")?.to_os_string(); + lock_name.push(".lock"); + let lock = self.path.with_file_name(lock_name); + for existing in [directory, lock.as_path(), self.path.as_path()] { + if existing.exists() { + require_root_only_writable(existing)?; + } + } if !directory.exists() { std::fs::create_dir_all(directory).with_context(|| format!("failed to create {}", directory.display()))?; set_mode(directory, DIR_MODE)?; } - let mut lock_name = self.path.file_name().context("control path has no file name")?.to_os_string(); - lock_name.push(".lock"); - let lock = self.path.with_file_name(lock_name); if !lock.exists() { std::fs::OpenOptions::new() .create(true) diff --git a/crates/mind-body/src/main.rs b/crates/mind-body/src/main.rs index f8e771f..2b620fe 100644 --- a/crates/mind-body/src/main.rs +++ b/crates/mind-body/src/main.rs @@ -1,10 +1,26 @@ //! `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. +//! clock, the two things the library never reads, and owns the process umask. use std::path::Path; +/// CultCache stages the store file with the process umask and offers no mode, +/// so the umask is what keeps a staged file from being group- or world-writable +/// for the instant between its rename and the writer's chmod. The binary owns +/// its process state; the library never touches the umask. +#[cfg(unix)] +fn restrict_umask() { + // SAFETY: umask only sets this process's file-creation mask. + unsafe { + libc::umask(0o077); + } +} + +#[cfg(not(unix))] +fn restrict_umask() {} + fn main() { + restrict_umask(); 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( diff --git a/crates/mind-body/tests/control.rs b/crates/mind-body/tests/control.rs index 97b8101..9ce3a56 100644 --- a/crates/mind-body/tests/control.rs +++ b/crates/mind-body/tests/control.rs @@ -1,10 +1,13 @@ //! 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`. +//! nothing touches `/etc` except the window probe, which writes a uniquely named +//! instance there as root and removes it. //! -//! 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. +//! The permission boundary is the filesystem's, but the writer refuses to write +//! through a directory, lock or store that is not root's or that group or world +//! can write, and creates its own files at fixed modes. The suite therefore runs +//! as root (the verify container does); the tests that need a second user become +//! uid 65534 with setpriv and skip with a message when they cannot. use std::path::{Path, PathBuf}; use std::time::Duration; @@ -346,7 +349,8 @@ mod modes { #[test] fn mode_child() { let Ok(role) = std::env::var(CHILD) else { return }; - let path = Path::new(&std::env::var(ROOT).unwrap()).join("eureka").join("control.cc"); + let instance = std::env::var("MIND_BODY_INSTANCE").unwrap_or_else(|_| "eureka".into()); + let path = Path::new(&std::env::var(ROOT).unwrap()).join(instance).join("control.cc"); match role.as_str() { "write" => { let mut writer = ControlWriter::open(&path).unwrap(); @@ -356,6 +360,18 @@ mod modes { "brake" => { ControlWriter::open(&path).unwrap().set_brake(true, at(0), "op").unwrap(); } + "attacker" => { + // Spins on an open-for-write of the live store; any success is a hit. + let stop = std::env::var("MIND_BODY_STOP").unwrap(); + let started = std::time::Instant::now(); + while !Path::new(&stop).exists() && started.elapsed() < Duration::from_secs(120) { + for _ in 0..2000 { + if std::fs::OpenOptions::new().write(true).open(&path).is_ok() { + std::process::exit(3); + } + } + } + } "intruder" => { assert!(ControlWriter::open(&path).and_then(|mut writer| writer.set_brake(false, at(1), "intruder")).is_err()); assert!(ControlWriter::open(Path::new(&std::env::var(ROOT).unwrap()).join("ghost").join("control.cc")) @@ -419,3 +435,137 @@ mod modes { assert!(status.success(), "the intruder child asserted a refusal that did not happen"); } } + +#[cfg(unix)] +mod unsafe_installs { + use super::*; + use mind_body::control::UnsafeStore; + use std::os::unix::fs::PermissionsExt; + use std::process::Command; + + fn running_as_root() -> bool { + Command::new("id").arg("-u").output().map(|out| String::from_utf8_lossy(&out.stdout).trim() == "0").unwrap_or(false) + } + + fn chmod(path: &Path, mode: u32) { + std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).unwrap(); + } + + /// An instance directory at `dir_mode`, optionally with a lock and a store + /// at the given modes, all root's (the suite runs as root). + fn install(dir_mode: u32, lock_mode: Option, store_mode: Option) -> (tempfile::TempDir, PathBuf) { + let root = tempfile::tempdir().unwrap(); + let instance = root.path().join("eureka"); + std::fs::create_dir(&instance).unwrap(); + for (name, mode) in [("control.cc.lock", lock_mode), ("control.cc", store_mode)] { + if let Some(mode) = mode { + std::fs::write(instance.join(name), b"").unwrap(); + chmod(&instance.join(name), mode); + } + } + chmod(&instance, dir_mode); + (root, instance) + } + + fn refusal(instance: &Path) -> UnsafeStore { + let path = instance.join("control.cc"); + let error = ControlWriter::open(&path).unwrap().set_brake(true, at(0), "op").expect_err("an unsafe install must be refused"); + error.downcast::().expect("the refusal is the typed UnsafeStore error") + } + + #[test] + fn a_group_or_world_writable_directory_lock_or_store_is_refused_and_nothing_is_written() { + if !running_as_root() { + eprintln!("SKIPPED: needs root to own the fixtures"); + return; + } + let cases: [(&str, u32, Option, Option, &str); 5] = [ + ("world-writable directory", 0o777, None, None, ""), + ("group-writable directory", 0o775, None, None, ""), + ("world-writable lock", 0o755, Some(0o666), None, "control.cc.lock"), + ("group-writable store", 0o755, Some(0o644), Some(0o664), "control.cc"), + ("world-writable store", 0o755, None, Some(0o666), "control.cc"), + ]; + for (what, dir_mode, lock_mode, store_mode, culprit) in cases { + let (_root, instance) = install(dir_mode, lock_mode, store_mode); + let before: Vec<_> = std::fs::read_dir(&instance).unwrap().map(|entry| entry.unwrap().file_name()).collect(); + let unsafe_store = refusal(&instance); + let expected = if culprit.is_empty() { instance.clone() } else { instance.join(culprit) }; + assert_eq!(unsafe_store.path, expected, "{what}: names the offending path"); + assert!(unsafe_store.mode & 0o022 != 0, "{what}: names the mode"); + assert!(unsafe_store.to_string().contains(&expected.display().to_string()), "{what}: the message names the path"); + let mut after: Vec<_> = std::fs::read_dir(&instance).unwrap().map(|entry| entry.unwrap().file_name()).collect(); + let mut before = before; + before.sort(); + after.sort(); + assert_eq!(before, after, "{what}: a refused write creates nothing"); + } + } + + #[test] + fn a_directory_not_owned_by_root_is_refused() { + if !running_as_root() { + eprintln!("SKIPPED: needs root to chown"); + return; + } + let (_root, instance) = install(0o755, None, None); + std::os::unix::fs::chown(&instance, Some(65534), Some(65534)).unwrap(); + let unsafe_store = refusal(&instance); + assert_eq!((unsafe_store.path, unsafe_store.uid), (instance, 65534)); + } + + #[test] + fn a_root_owned_install_without_group_or_world_write_is_accepted() { + if !running_as_root() { + eprintln!("SKIPPED: needs root to own the fixtures"); + return; + } + for dir_mode in [0o755, 0o750, 0o700] { + let (_root, instance) = install(dir_mode, Some(0o644), Some(0o644)); + ControlWriter::open(instance.join("control.cc")).unwrap().set_brake(true, at(0), "op").unwrap(); + } + } + + /// The window between CultCache's rename and the writer's chmod, probed by + /// a non-root process spinning open-for-write on the live store while the + /// real binary writes it under a starting umask of 000. The binary sets + /// its own umask, so the staged file is never writable by anyone else. + #[test] + fn no_non_root_open_for_write_succeeds_while_the_binary_writes_under_umask_000() { + if !running_as_root() || Command::new("setpriv").arg("--version").output().is_err() { + eprintln!("SKIPPED: needs root and setpriv; it writes under /etc/gamecult/minds in the verify container"); + return; + } + let instance = format!("window-probe-{}", std::process::id()); + let live = Path::new(mind_body::control::CONTROL_ROOT).join(&instance); + let work = tempfile::tempdir().unwrap(); + chmod(work.path(), 0o755); + let attacker_bin = work.path().join("attacker"); + std::fs::copy(std::env::current_exe().unwrap(), &attacker_bin).unwrap(); + chmod(&attacker_bin, 0o755); + let stop = work.path().join("stop"); + let mut attacker = Command::new("setpriv") + .args(["--reuid=65534", "--regid=65534", "--clear-groups"]) + .arg(&attacker_bin) + .args(["--exact", "modes::mode_child", "--nocapture"]) + .env("MIND_BODY_MODE_CHILD", "attacker") + .env("MIND_BODY_MODE_ROOT", live.parent().unwrap()) + .env("MIND_BODY_STOP", &stop) + .env("MIND_BODY_INSTANCE", &instance) + .spawn() + .unwrap(); + let bin = env!("CARGO_BIN_EXE_mind-control"); + for round in 0..60 { + let verb = if round % 2 == 0 { "release" } else { "hold" }; + let status = Command::new("sh") + .args(["-c", "umask 000; exec \"$@\"", "sh", bin, "--instance", &instance, "brake", verb]) + .status() + .unwrap(); + assert!(status.success()); + } + std::fs::write(&stop, b"").unwrap(); + let attacker_status = attacker.wait().unwrap(); + std::fs::remove_dir_all(&live).unwrap(); + assert_eq!(attacker_status.code(), Some(0), "a non-root process opened the live store for write"); + } +} From 8e6b72c21ba7759e149b8a21f5e8c6465c6c95b2 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 9 Oct 2026 16:40:18 +0200 Subject: [PATCH 09/14] mind-body: the world-writable store fixture carries a lock, as opening the writer on a store would create one Co-Authored-By: Claude Opus 5.5 --- crates/mind-body/tests/control.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/mind-body/tests/control.rs b/crates/mind-body/tests/control.rs index 9ce3a56..d648db1 100644 --- a/crates/mind-body/tests/control.rs +++ b/crates/mind-body/tests/control.rs @@ -484,7 +484,7 @@ mod unsafe_installs { ("group-writable directory", 0o775, None, None, ""), ("world-writable lock", 0o755, Some(0o666), None, "control.cc.lock"), ("group-writable store", 0o755, Some(0o644), Some(0o664), "control.cc"), - ("world-writable store", 0o755, None, Some(0o666), "control.cc"), + ("world-writable store", 0o755, Some(0o644), Some(0o666), "control.cc"), ]; for (what, dir_mode, lock_mode, store_mode, culprit) in cases { let (_root, instance) = install(dir_mode, lock_mode, store_mode); From 1c2964b6ad3883e79105b7ada5f32abb3fa35a77 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 9 Oct 2026 16:43:00 +0200 Subject: [PATCH 10/14] mind-body: the window probe also opens the staged file, the wider window Under umask 000 with the binary's umask removed the first probe never hit. Co-Authored-By: Claude Opus 5.5 --- crates/mind-body/tests/control.rs | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/crates/mind-body/tests/control.rs b/crates/mind-body/tests/control.rs index d648db1..8896b22 100644 --- a/crates/mind-body/tests/control.rs +++ b/crates/mind-body/tests/control.rs @@ -365,9 +365,17 @@ mod modes { let stop = std::env::var("MIND_BODY_STOP").unwrap(); let started = std::time::Instant::now(); while !Path::new(&stop).exists() && started.elapsed() < Duration::from_secs(120) { - for _ in 0..2000 { - if std::fs::OpenOptions::new().write(true).open(&path).is_ok() { - std::process::exit(3); + // The live store, and anything else in its directory (the staged + // file CultCache writes before its rename is the wider window). + for _ in 0..200 { + let mut targets = vec![path.clone()]; + if let Ok(entries) = std::fs::read_dir(path.parent().unwrap()) { + targets.extend(entries.flatten().map(|entry| entry.path())); + } + for target in targets { + if std::fs::OpenOptions::new().write(true).open(&target).is_ok() { + std::process::exit(3); + } } } } @@ -555,7 +563,7 @@ mod unsafe_installs { .spawn() .unwrap(); let bin = env!("CARGO_BIN_EXE_mind-control"); - for round in 0..60 { + for round in 0..100 { let verb = if round % 2 == 0 { "release" } else { "hold" }; let status = Command::new("sh") .args(["-c", "umask 000; exec \"$@\"", "sh", bin, "--instance", &instance, "brake", verb]) From eb8aefc0fb17b74a22b3a2243c2a9f5f8efdc525 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 9 Oct 2026 16:43:59 +0200 Subject: [PATCH 11/14] mind-body: directories the writer creates are 0755 ancestors included; the probe reports its sweeps Under the binary's umask 077 a first write would have left /etc/gamecult/minds 0700 and unreadable by the units. The attacker reports how much it swept. Co-Authored-By: Claude Opus 5.5 --- crates/mind-body/src/control.rs | 11 ++++++++++- crates/mind-body/tests/control.rs | 28 ++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/crates/mind-body/src/control.rs b/crates/mind-body/src/control.rs index f42dfc4..db3ad98 100644 --- a/crates/mind-body/src/control.rs +++ b/crates/mind-body/src/control.rs @@ -1,4 +1,13 @@ -//! The operator's brake and burn-rate dial, and the one reader of them. + // Every directory this write creates is 0755, ancestors included: a + // restrictive process umask would otherwise leave /etc/gamecult/minds + // untraversable by the units that only read. + let missing: Vec<&Path> = directory.ancestors().take_while(|ancestor| !ancestor.exists()).collect(); + if !missing.is_empty() { + std::fs::create_dir_all(directory).with_context(|| format!("failed to create {}", directory.display()))?; + for created in missing { + set_mode(created, DIR_MODE)?; + } + }//! 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. diff --git a/crates/mind-body/tests/control.rs b/crates/mind-body/tests/control.rs index 8896b22..1d9131e 100644 --- a/crates/mind-body/tests/control.rs +++ b/crates/mind-body/tests/control.rs @@ -364,6 +364,7 @@ mod modes { // Spins on an open-for-write of the live store; any success is a hit. let stop = std::env::var("MIND_BODY_STOP").unwrap(); let started = std::time::Instant::now(); + let (mut sweeps, mut seen) = (0u64, 0usize); while !Path::new(&stop).exists() && started.elapsed() < Duration::from_secs(120) { // The live store, and anything else in its directory (the staged // file CultCache writes before its rename is the wider window). @@ -372,6 +373,8 @@ mod modes { if let Ok(entries) = std::fs::read_dir(path.parent().unwrap()) { targets.extend(entries.flatten().map(|entry| entry.path())); } + sweeps += 1; + seen = seen.max(targets.len()); for target in targets { if std::fs::OpenOptions::new().write(true).open(&target).is_ok() { std::process::exit(3); @@ -379,6 +382,7 @@ mod modes { } } } + eprintln!("attacker: {sweeps} sweeps, at most {seen} entries seen"); } "intruder" => { assert!(ControlWriter::open(&path).and_then(|mut writer| writer.set_brake(false, at(1), "intruder")).is_err()); @@ -577,3 +581,27 @@ mod unsafe_installs { assert_eq!(attacker_status.code(), Some(0), "a non-root process opened the live store for write"); } } + +#[cfg(unix)] +#[test] +fn directories_the_writer_creates_are_0755_ancestors_included_under_a_restrictive_umask() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let mode = |path: &Path| std::fs::metadata(path).unwrap().permissions().mode() & 0o777; + let top = dir.path().join("gamecult"); + let path = top.join("minds").join("eureka").join("control.cc"); + // Create nothing but run the writer; the umask is the test process's own, so + // the child role does the write under 077. + let status = std::process::Command::new("sh") + .args(["-c", "umask 077; exec \"$@\" --exact modes::mode_child --nocapture", "sh"]) + .arg(std::env::current_exe().unwrap()) + .env("MIND_BODY_MODE_CHILD", "brake") + .env("MIND_BODY_MODE_ROOT", top.join("minds")) + .status() + .unwrap(); + assert!(status.success()); + assert!(path.exists()); + for created in [&top, &top.join("minds"), &top.join("minds").join("eureka")] { + assert_eq!(mode(created), 0o755, "{}", created.display()); + } +} From 44c5410bbfe6f25b0285770ddade31cdcc893801 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 9 Oct 2026 16:45:14 +0200 Subject: [PATCH 12/14] mind-body: create missing ancestor directories 0755 (the previous commit's edit mangled control.rs) Co-Authored-By: Claude Opus 5.5 --- crates/mind-body/src/control.rs | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/crates/mind-body/src/control.rs b/crates/mind-body/src/control.rs index db3ad98..bf751e2 100644 --- a/crates/mind-body/src/control.rs +++ b/crates/mind-body/src/control.rs @@ -1,13 +1,4 @@ - // Every directory this write creates is 0755, ancestors included: a - // restrictive process umask would otherwise leave /etc/gamecult/minds - // untraversable by the units that only read. - let missing: Vec<&Path> = directory.ancestors().take_while(|ancestor| !ancestor.exists()).collect(); - if !missing.is_empty() { - std::fs::create_dir_all(directory).with_context(|| format!("failed to create {}", directory.display()))?; - for created in missing { - set_mode(created, DIR_MODE)?; - } - }//! The operator's brake and burn-rate dial, and the one reader of them. +//! 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. @@ -295,9 +286,15 @@ impl ControlWriter { require_root_only_writable(existing)?; } } - if !directory.exists() { + // Every directory this write creates is 0755, ancestors included: a + // restrictive process umask would otherwise leave /etc/gamecult/minds + // untraversable by the units that only read. + let missing: Vec<&Path> = directory.ancestors().take_while(|ancestor| !ancestor.exists()).collect(); + if !missing.is_empty() { std::fs::create_dir_all(directory).with_context(|| format!("failed to create {}", directory.display()))?; - set_mode(directory, DIR_MODE)?; + for created in missing { + set_mode(created, DIR_MODE)?; + } } if !lock.exists() { std::fs::OpenOptions::new() From a50b25c08e880a06eb7a1ab6806535e4e95f4be2 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 9 Oct 2026 16:53:27 +0200 Subject: [PATCH 13/14] mind-body: mind-control takes --root ; the window probe runs in a tempdir Soul s3: the window probe wrote a window-probe- instance under the real /etc/gamecult/minds. A leading --root argv flag (argv, because sudo -E carries the environment) replaces the default root; the probe points the real binary at a tempdir, whose drop removes whatever a failed assert leaves. No test names /etc/gamecult any more; the path-derivation test uses CONTROL_ROOT. Co-Authored-By: Claude Opus 5.5 --- crates/mind-body/src/cli.rs | 13 +++++++++---- crates/mind-body/tests/cli.rs | 24 ++++++++++++++++++++++++ crates/mind-body/tests/control.rs | 18 +++++++++++------- 3 files changed, 44 insertions(+), 11 deletions(-) diff --git a/crates/mind-body/src/cli.rs b/crates/mind-body/src/cli.rs index e7aa841..491b64b 100644 --- a/crates/mind-body/src/cli.rs +++ b/crates/mind-body/src/cli.rs @@ -3,7 +3,7 @@ //! root-owned and the filesystem is the authority. //! //! ```text -//! mind-control --instance show +//! mind-control [--root ] --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 @@ -21,7 +21,7 @@ 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"; + "usage: mind-control [--root ] --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. @@ -33,10 +33,15 @@ pub fn operator_name(sudo_user: Option, user: Option) -> String .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 +/// Runs one command against `//control.cc`. `root` is the +/// default; a leading `--root ` argument replaces it (argv, not environment, +/// because `sudo -E` carries the environment). 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 (root, args) = match args { + [flag, dir, rest @ ..] if flag == "--root" => (Path::new(dir), rest), + _ => (root, args), + }; let (instance, command) = match args { [flag, instance, command @ ..] if flag == "--instance" => (Slug(instance.clone()), command), _ => bail!("{USAGE}"), diff --git a/crates/mind-body/tests/cli.rs b/crates/mind-body/tests/cli.rs index 1c01625..ff6e8ea 100644 --- a/crates/mind-body/tests/cli.rs +++ b/crates/mind-body/tests/cli.rs @@ -110,3 +110,27 @@ fn the_binary_exits_nonzero_with_the_usage_when_refused() { assert!(String::from_utf8_lossy(&output.stderr).contains("usage: mind-control")); assert!(output.stdout.is_empty()); } + +#[test] +fn a_leading_root_flag_replaces_the_default_root() { + let default = tempfile::tempdir().unwrap(); + let chosen = tempfile::tempdir().unwrap(); + let chosen_arg = chosen.path().to_str().unwrap(); + let (result, _) = go(default.path(), &["--root", chosen_arg, "--instance", "eureka", "brake", "release"], "op", 0); + result.unwrap(); + assert!(chosen.path().join("eureka").join("control.cc").exists(), "the write went to the chosen root"); + assert_eq!(std::fs::read_dir(default.path()).unwrap().count(), 0, "the default root was not touched"); + // Without the flag the default applies. + go(default.path(), &["--instance", "eureka", "brake", "release"], "op", 1).0.unwrap(); + assert!(default.path().join("eureka").join("control.cc").exists()); +} + +#[test] +fn a_root_flag_without_a_directory_or_an_instance_is_refused_and_writes_nothing() { + let default = tempfile::tempdir().unwrap(); + for args in [&["--root"][..], &["--root", "/nonexistent-root-for-test"][..]] { + assert!(go(default.path(), args, "op", 0).0.is_err(), "{args:?}"); + } + assert_eq!(std::fs::read_dir(default.path()).unwrap().count(), 0); + assert!(!std::path::Path::new("/nonexistent-root-for-test").exists()); +} diff --git a/crates/mind-body/tests/control.rs b/crates/mind-body/tests/control.rs index 1d9131e..10505d4 100644 --- a/crates/mind-body/tests/control.rs +++ b/crates/mind-body/tests/control.rs @@ -1,6 +1,7 @@ //! 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` except the window probe, which writes a uniquely named +//! nothing touches `/etc`, including the window probe, which points the real +//! binary at a tempdir with `--root`. //! instance there as root and removes it. //! //! The permission boundary is the filesystem's, but the writer refuses to write @@ -290,7 +291,7 @@ fn reading_writes_nothing_and_takes_no_lock_file() { #[test] fn the_control_path_is_derived_from_a_valid_instance_only() { use eureka_pipeline::Slug; - let root = Path::new("/etc/gamecult/minds"); + let root = Path::new(mind_body::control::CONTROL_ROOT); 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:?}"); @@ -545,13 +546,17 @@ mod unsafe_installs { #[test] fn no_non_root_open_for_write_succeeds_while_the_binary_writes_under_umask_000() { if !running_as_root() || Command::new("setpriv").arg("--version").output().is_err() { - eprintln!("SKIPPED: needs root and setpriv; it writes under /etc/gamecult/minds in the verify container"); + eprintln!("SKIPPED: needs root and setpriv"); return; } let instance = format!("window-probe-{}", std::process::id()); - let live = Path::new(mind_body::control::CONTROL_ROOT).join(&instance); + // A root-owned 0755 tempdir is the control root; its drop removes whatever + // the probe left, even after a failed assert. let work = tempfile::tempdir().unwrap(); chmod(work.path(), 0o755); + let minds = work.path().join("minds"); + std::fs::create_dir(&minds).unwrap(); + chmod(&minds, 0o755); let attacker_bin = work.path().join("attacker"); std::fs::copy(std::env::current_exe().unwrap(), &attacker_bin).unwrap(); chmod(&attacker_bin, 0o755); @@ -561,7 +566,7 @@ mod unsafe_installs { .arg(&attacker_bin) .args(["--exact", "modes::mode_child", "--nocapture"]) .env("MIND_BODY_MODE_CHILD", "attacker") - .env("MIND_BODY_MODE_ROOT", live.parent().unwrap()) + .env("MIND_BODY_MODE_ROOT", &minds) .env("MIND_BODY_STOP", &stop) .env("MIND_BODY_INSTANCE", &instance) .spawn() @@ -570,14 +575,13 @@ mod unsafe_installs { for round in 0..100 { let verb = if round % 2 == 0 { "release" } else { "hold" }; let status = Command::new("sh") - .args(["-c", "umask 000; exec \"$@\"", "sh", bin, "--instance", &instance, "brake", verb]) + .args(["-c", "umask 000; exec \"$@\"", "sh", bin, "--root", minds.to_str().unwrap(), "--instance", &instance, "brake", verb]) .status() .unwrap(); assert!(status.success()); } std::fs::write(&stop, b"").unwrap(); let attacker_status = attacker.wait().unwrap(); - std::fs::remove_dir_all(&live).unwrap(); assert_eq!(attacker_status.code(), Some(0), "a non-root process opened the live store for write"); } } From cc66193961cf5f51c8530c4d2d90de297c8f54f6 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 9 Oct 2026 16:55:51 +0200 Subject: [PATCH 14/14] mind-body: drop a stale line from the control test header Co-Authored-By: Claude Opus 5.5 --- crates/mind-body/tests/control.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/mind-body/tests/control.rs b/crates/mind-body/tests/control.rs index 10505d4..8ab013e 100644 --- a/crates/mind-body/tests/control.rs +++ b/crates/mind-body/tests/control.rs @@ -2,7 +2,6 @@ //! works in a temporary directory through the same file door the units use; //! nothing touches `/etc`, including the window probe, which points the real //! binary at a tempdir with `--root`. -//! instance there as root and removes it. //! //! The permission boundary is the filesystem's, but the writer refuses to write //! through a directory, lock or store that is not root's or that group or world