Skip to main content

planarian_daemon/
config.rs

1//! `/etc/planarian/config.json`, written by the NixOS module, and the
2//! canonical probes shipped in the core package (I10).
3
4use planarian_changes::CatalogConfig;
5use planarian_policy::{Settings, Tables};
6use planarian_schema::{ChangeId, ProbeDef, Sensitivities, Tier};
7use serde::{Deserialize, Serialize};
8use std::collections::{BTreeMap, BTreeSet};
9use std::path::{Path, PathBuf};
10
11pub const CONFIG: &str = "/etc/planarian/config.json";
12/// The configuration of the generation this machine booted (§12d, P16).
13pub const BOOTED_CONFIG: &str = "/run/booted-system/etc/planarian/config.json";
14/// The running generation's record of its core files' hashes (I2, §12d),
15/// the one `planarian-recovery` checks; `PLANARIAN_CORE_HASH` overrides it.
16pub const CORE_HASH_RECORD: &str = "/etc/planarian/core.sha256";
17
18/// `SystemState.core_hash` (P19): the hash of the recorded core-hash file's
19/// bytes. Zero only when there is no record (a development build); any
20/// other read failure is an error, never a value (§18 rule 21). A damaged
21/// record is the watchdog's integrity finding (I2), not this.
22pub fn core_hash(record: &std::path::Path) -> Result<planarian_schema::Hash, String> {
23    match std::fs::read(record) {
24        Ok(b) => Ok(planarian_schema::Hash::of(&b)),
25        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(planarian_schema::Hash::ZERO),
26        Err(e) => Err(format!("{}: {e}", record.display())),
27    }
28}
29
30#[derive(Clone, Debug, Default, Serialize, Deserialize)]
31#[serde(rename_all = "camelCase", default)]
32pub struct RuntimeOptionConfig {
33    pub path: String,
34    pub keywords: Vec<String>,
35    pub current: Option<String>,
36    pub step: String,
37    pub min: String,
38    pub max: String,
39}
40
41#[derive(Clone, Debug, Serialize, Deserialize)]
42#[serde(rename_all = "camelCase", default)]
43pub struct FileConfig {
44    pub owner: PathBuf,
45    pub flake_attr: String,
46    pub state_dir: PathBuf,
47    pub max_tier: Tier,
48    pub user_controllable_units: BTreeSet<String>,
49    pub user_controllable_changes: BTreeSet<String>,
50    pub user_controllable_options: BTreeSet<String>,
51    pub runtime_allowlist: BTreeSet<String>,
52    pub option_sensitivity: BTreeMap<String, Sensitivities>,
53    pub catalog: CatalogConfig,
54    pub runtime_options: Vec<RuntimeOptionConfig>,
55    /// Device unit name → sysfs path.
56    pub devices: BTreeMap<String, PathBuf>,
57    pub admin_group: String,
58    /// §12b collection bounds (`services.planarian.gc`).
59    pub gc: planarian_schema::gc::GcBudget,
60    /// Seconds after a unit comes up during which a failing health probe is
61    /// not yet a fault (`services.planarian.startupGrace`).
62    pub startup_grace_secs: u64,
63    /// Compositor process names for the per-session desktop check (§9).
64    #[serde(default)]
65    pub session_compositors: Vec<String>,
66    /// The failure posture (§12d). Phase A ships Standard.
67    #[serde(default)]
68    pub posture: planarian_schema::Posture,
69    /// `services.planarian.bootReadyTimeout`, seconds (§12d).
70    #[serde(default = "default_boot_ready_secs")]
71    pub boot_ready_timeout_secs: u64,
72    pub journal_bytes: u64,
73    /// The signed external desired-state source (§9 hook); none by default.
74    #[serde(default)]
75    pub external_source: Option<crate::hooks::ExternalSourceConfig>,
76    /// Seconds between signed health reports (§10 hook); 0 turns them off.
77    #[serde(default = "default_health_secs")]
78    pub health_report_secs: u64,
79    /// This configuration is the gym's own VM test, which may run the test
80    /// build (§15, B-13). Anywhere else, a test build is an Integrity failure.
81    #[serde(default)]
82    pub allow_test_build: bool,
83    /// Limits on launched agents (`services.planarian.agents.limits`, §12d).
84    #[serde(default)]
85    pub agent_limits: planarian_schema::backend::AgentLimits,
86}
87
88fn default_health_secs() -> u64 {
89    3600
90}
91
92impl Default for FileConfig {
93    fn default() -> Self {
94        let s = Settings::default();
95        FileConfig {
96            owner: "/etc/nixos".into(),
97            flake_attr: "nixosConfigurations.default".into(),
98            state_dir: "/var/lib/planarian".into(),
99            max_tier: Tier::Tested,
100            user_controllable_units: s.user_controllable_units.into_iter().collect(),
101            user_controllable_changes: s.user_controllable_changes.into_iter().map(|c| c.0).collect(),
102            user_controllable_options: s.user_controllable_options.into_iter().collect(),
103            runtime_allowlist: ["font.scale", "wifi.network"].map(String::from).into(),
104            option_sensitivity: BTreeMap::new(),
105            catalog: CatalogConfig::default(),
106            runtime_options: vec![],
107            devices: BTreeMap::new(),
108            admin_group: "planarian-admin".into(),
109            gc: Default::default(),
110            startup_grace_secs: planarian_incident::STARTUP_GRACE.0 / 1000,
111            journal_bytes: planarian_txn::store::DEFAULT_CAPACITY,
112            posture: Default::default(),
113            boot_ready_timeout_secs: default_boot_ready_secs(),
114            session_compositors: vec![],
115            external_source: None,
116            health_report_secs: default_health_secs(),
117            allow_test_build: false,
118            agent_limits: Default::default(),
119        }
120    }
121}
122
123fn default_boot_ready_secs() -> u64 {
124    120
125}
126
127/// What the failure posture is, and what is waiting (§12d, P16). A
128/// generation switched live can strengthen the posture or shorten the
129/// timeout at once; a weaker declaration takes effect when it is booted,
130/// because the posture follows the booted generation until then.
131#[derive(Clone, Debug, Default, PartialEq, Eq)]
132pub struct PostureDecl {
133    /// What the running generation declares.
134    pub declared: planarian_schema::Posture,
135    pub declared_timeout_secs: u64,
136    /// What the booted generation declares, if it declares one.
137    pub booted: Option<(planarian_schema::Posture, u64)>,
138}
139
140impl PostureDecl {
141    pub fn of(current: &FileConfig, booted: Option<&FileConfig>) -> PostureDecl {
142        PostureDecl {
143            declared: current.posture,
144            declared_timeout_secs: current.boot_ready_timeout_secs,
145            booted: booted.map(|b| (b.posture, b.boot_ready_timeout_secs)),
146        }
147    }
148
149    /// The stronger of the two postures.
150    pub fn in_force(&self) -> planarian_schema::Posture {
151        self.booted.map_or(self.declared, |(p, _)| p.max(self.declared))
152    }
153
154    /// The shorter of the two timeouts.
155    pub fn timeout_in_force(&self) -> u64 {
156        self.booted.map_or(self.declared_timeout_secs, |(_, t)| t.min(self.declared_timeout_secs))
157    }
158
159    /// Whether a weaker declaration waits for the next boot.
160    pub fn weaker_waiting(&self) -> bool {
161        self.in_force() != self.declared || self.timeout_in_force() != self.declared_timeout_secs
162    }
163}
164
165impl FileConfig {
166    pub fn load(path: &Path) -> Result<FileConfig, String> {
167        let text = std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?;
168        serde_json::from_str(&text).map_err(|e| format!("{}: {e}", path.display()))
169    }
170
171    pub fn settings(&self) -> Settings {
172        Settings {
173            max_tier: self.max_tier.min(Tier::Tested),
174            user_controllable_units: self.user_controllable_units.iter().cloned().collect(),
175            user_controllable_changes: self.user_controllable_changes.iter().map(|c| ChangeId(c.clone())).collect(),
176            user_controllable_options: self.user_controllable_options.iter().cloned().collect(),
177        }
178    }
179
180    pub fn tables(&self) -> Tables {
181        let catalog = planarian_changes::entries(&self.catalog).into_iter().map(|e| e.id).collect();
182        Tables {
183            catalog,
184            runtime_allowlist: self.runtime_allowlist.iter().cloned().collect(),
185            option_sensitivity: self.option_sensitivity.iter().map(|(k, v)| (k.clone(), *v)).collect(),
186            model_catalog: Default::default(),
187        }
188    }
189
190    pub fn runtime_options(&self) -> Vec<planarian_intent::RuntimeOption> {
191        let milli = |s: &str| -> i64 {
192            let (i, f) = s.split_once('.').unwrap_or((s, ""));
193            i.parse::<i64>().unwrap_or(0) * 1000 + format!("{f:0<3}").get(..3).and_then(|x| x.parse().ok()).unwrap_or(0)
194        };
195        self.runtime_options
196            .iter()
197            .map(|o| planarian_intent::RuntimeOption {
198                path: o.path.clone(),
199                keywords: o.keywords.clone(),
200                current: o.current.clone().map(planarian_schema::Value::Str),
201                step_milli: milli(&o.step),
202                min_milli: milli(&o.min),
203                max_milli: milli(&o.max),
204            })
205            .collect()
206    }
207}
208
209/// Canonical probes from the core package: `<prefix>/share/planarian/canonical.json`
210/// next to the running binary, never from the generation (I10).
211pub fn load_canonical() -> Result<BTreeMap<String, Vec<ProbeDef>>, String> {
212    let exe = std::env::current_exe().map_err(|e| e.to_string())?;
213    let prefix = exe.parent().and_then(Path::parent).ok_or("no install prefix")?;
214    let p = prefix.join("share/planarian/canonical.json");
215    let text = std::fs::read_to_string(&p).map_err(|e| format!("{}: {e}", p.display()))?;
216    serde_json::from_str(&text).map_err(|e| format!("{}: {e}", p.display()))
217}
218
219/// Is `uid` root or a member of `group` (by /etc/group and /etc/passwd)?
220pub fn is_admin(uid: u32, group: &str) -> bool {
221    if uid == 0 {
222        return true;
223    }
224    let passwd = std::fs::read_to_string("/etc/passwd").unwrap_or_default();
225    let user = passwd.lines().find_map(|l| {
226        let f: Vec<&str> = l.split(':').collect();
227        (f.len() > 3 && f[2].parse::<u32>().ok() == Some(uid)).then(|| (f[0].to_string(), f[3].to_string()))
228    });
229    let Some((name, primary_gid)) = user else { return false };
230    let groups = std::fs::read_to_string("/etc/group").unwrap_or_default();
231    groups.lines().any(|l| {
232        let f: Vec<&str> = l.split(':').collect();
233        f.len() > 3 && f[0] == group && (f[2] == primary_gid || f[3].split(',').any(|m| m == name))
234    })
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240
241    /// The shipped canonical probes parse and keep the probe contract (§10, §18.4).
242    #[test]
243    fn shipped_canonical_probes_are_valid() {
244        let text = include_str!("../../../share/canonical.json");
245        let c: BTreeMap<String, Vec<ProbeDef>> = serde_json::from_str(text).unwrap();
246        for (unit, probes) in &c {
247            for p in probes {
248                assert_eq!(p.origin, planarian_schema::ProbeOrigin::Canonical, "{unit}");
249                p.check_contract().unwrap_or_else(|e| panic!("{unit}: {e:?}"));
250            }
251        }
252        assert!(c.contains_key("sshd") && c.contains_key("graphical-session") && c.contains_key("fs-journal"));
253    }
254
255    /// P24 (#28 review): the shipped canonical map keys wireplumber's probe
256    /// by name (`"wireplumber"`), and the module declares wireplumber as the
257    /// user template `service@0:wireplumber`. The assembler matches by name,
258    /// so the template, and each session's instance of it, carries
259    /// `wireplumber-active` as a Canonical probe.
260    #[test]
261    fn shipped_canonical_probe_reaches_the_wireplumber_user_unit() {
262        use planarian_schema::{ProbeId, ProbeOrigin, SessionInfo, Timestamp, UnitId};
263        let text = include_str!("../../../share/canonical.json");
264        let canonical: BTreeMap<String, Vec<ProbeDef>> = serde_json::from_str(text).unwrap();
265        let template: UnitId = "service@0:wireplumber".parse().unwrap();
266        let mut def = planarian_fake::fixtures::gym_user_units().into_iter().next().expect("a user unit fixture");
267        def.id = template.clone();
268        def.probes.clear();
269        let manifest = planarian_schema::txn::Manifest { units: Default::default(), user_units: vec![def] };
270        let mut asm = planarian_state::Assembler::new(manifest, canonical);
271        let active = ProbeId("wireplumber-active".into());
272        let has_it = |d: &planarian_schema::UnitDef| {
273            d.probes.iter().any(|p| p.id == active && p.origin == ProbeOrigin::Canonical)
274        };
275        let t = asm.manifest().user_units.iter().find(|d| d.id == template).expect("the template");
276        assert!(has_it(t), "template probes: {:?}", t.probes);
277
278        // Instantiated for a user with an established session.
279        asm.sessions = vec![SessionInfo {
280            id: "s1".into(),
281            uid: 1000,
282            class: "user".into(),
283            seat: Some("seat0".into()),
284            remote: false,
285            created: Timestamp(0),
286            active: true,
287            idle: false,
288        }];
289        let defs = asm.defs();
290        let inst = defs.values().find(|d| d.id.name == "wireplumber").expect("an instance for uid 1000");
291        assert!(matches!(inst.id.scope, planarian_schema::Scope::User { uid: 1000, .. }), "{:?}", inst.id);
292        assert!(has_it(inst), "instance probes: {:?}", inst.probes);
293    }
294
295    #[test]
296    fn default_config_roundtrips() {
297        let c = FileConfig::default();
298        let j = serde_json::to_string(&c).unwrap();
299        let back: FileConfig = serde_json::from_str(&j).unwrap();
300        assert_eq!(back.max_tier, c.max_tier);
301        assert!(serde_json::from_str::<FileConfig>("{}").is_ok(), "every field has a default");
302    }
303}
304
305#[cfg(test)]
306mod posture_tests {
307    use super::*;
308    use planarian_schema::Posture;
309
310    /// P16: lowering the posture or lengthening the timeout in a generation
311    /// switched live waits for the boot; raising or shortening does not.
312    #[test]
313    fn a_weaker_posture_waits_for_the_boot() {
314        let with = |p: Posture, t: u64| FileConfig { posture: p, boot_ready_timeout_secs: t, ..Default::default() };
315        let booted = with(Posture::Standard, 120);
316        let lower = PostureDecl::of(&with(Posture::Relaxed, 600), Some(&booted));
317        assert_eq!((lower.in_force(), lower.timeout_in_force()), (Posture::Standard, 120));
318        assert!(lower.weaker_waiting());
319        let higher = PostureDecl::of(&with(Posture::Guarded, 60), Some(&booted));
320        assert_eq!((higher.in_force(), higher.timeout_in_force()), (Posture::Guarded, 60));
321        assert!(!higher.weaker_waiting());
322        let fresh = PostureDecl::of(&with(Posture::Relaxed, 600), None);
323        assert_eq!(fresh.in_force(), Posture::Relaxed, "no booted declaration: the running one");
324    }
325}