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()); + } +}