diff --git a/Cargo.lock b/Cargo.lock
index 4fcc88d..a46de8b 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,20 @@ 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",
+ "libc",
+ "rust_decimal",
+ "serde",
+ "tempfile",
+]
+
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -861,6 +933,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 +972,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 +1146,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 +1418,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 +1480,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 +1635,7 @@ dependencies = [
"cfg-if",
"once_cell",
"rustversion",
+ "serde",
"wasm-bindgen-macro",
"wasm-bindgen-shared",
]
@@ -1694,6 +1844,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..eab1e63
--- /dev/null
+++ b/crates/mind-body/Cargo.toml
@@ -0,0 +1,37 @@
+[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"] }
+
+[target.'cfg(unix)'.dependencies]
+libc = "0.2"
+
+[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..491b64b
--- /dev/null
+++ b/crates/mind-body/src/cli.rs
@@ -0,0 +1,125 @@
+//! The `mind-control` command line: the operator's hand on one instance's
+//! control store. It checks no caller identity: the file is
+//! root-owned and the filesystem is the authority.
+//!
+//! ```text
+//! 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
+//! ```
+
+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 [--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.
+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`. `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}"),
+ };
+ 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");
+ }
+ }
+ 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 need<'a>(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)?;
+ 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..bf751e2
--- /dev/null
+++ b/crates/mind-body/src/control.rs
@@ -0,0 +1,339 @@
+//! 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. 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;
+
+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,
+ 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()))
+}
+
+/// 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(())
+}
+
+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.pull_all_backing_stores()?;
+ 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.
+ /// 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)?;
+ }
+ }
+ // 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)?;
+ }
+ }
+ 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)?;
+ self.settle()
+ }
+
+ /// 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.prepare()?;
+ self.cache.put(DIAL_KEY, &dial)?;
+ self.settle()
+ }
+}
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..2b620fe
--- /dev/null
+++ b/crates/mind-body/src/main.rs
@@ -0,0 +1,37 @@
+//! `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, 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(
+ &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..ff6e8ea
--- /dev/null
+++ b/crates/mind-body/tests/cli.rs
@@ -0,0 +1,136 @@
+//! 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"],
+ &["--instances", "eureka", "brake", "release"],
+ &["eureka", "eureka", "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");
+}
+
+#[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());
+}
+
+#[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
new file mode 100644
index 0000000..8ab013e
--- /dev/null
+++ b/crates/mind-body/tests/control.rs
@@ -0,0 +1,610 @@
+//! 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`, including the window probe, which points the real
+//! binary at a tempdir with `--root`.
+//!
+//! 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;
+
+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(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:?}");
+ }
+}
+
+/// 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.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 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();
+ 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();
+ }
+ "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();
+ 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).
+ 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()));
+ }
+ 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);
+ }
+ }
+ }
+ }
+ 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());
+ 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(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(role, 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("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}");
+ }
+ }
+
+ 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("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();
+ 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");
+ }
+}
+
+#[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, 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);
+ 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");
+ return;
+ }
+ let instance = format!("window-probe-{}", std::process::id());
+ // 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);
+ 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", &minds)
+ .env("MIND_BODY_STOP", &stop)
+ .env("MIND_BODY_INSTANCE", &instance)
+ .spawn()
+ .unwrap();
+ let bin = env!("CARGO_BIN_EXE_mind-control");
+ 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, "--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();
+ 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());
+ }
+}