Skip to main content

planarian_changes/
lib.rs

1//! The change catalog (SPEC §9). Every `ApplyChange` names an entry here.
2//! An entry validates its arguments against permitted values and produces
3//! flat option assignments for `planarian/overrides.nix`. It never produces
4//! raw Nix. Risk is not read from here: the executor derives it from the
5//! evaluated generation (I14). `affected_modules` is only a hint.
6//!
7//! The permitted values and the option each entry writes are owner
8//! configuration (`services.planarian.catalog` in the module), exported as
9//! JSON and loaded into `CatalogConfig`.
10
11use planarian_schema::{ChangeId, OverrideSet, Value};
12use serde::{Deserialize, Serialize};
13use std::collections::BTreeMap;
14
15#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(rename_all = "camelCase", default)]
17pub struct ListenAddress {
18    pub option: String,
19    pub permitted: Vec<String>,
20}
21
22#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "camelCase", default)]
24pub struct CatalogConfig {
25    /// Service name → its `enable` option, e.g. `"sshd" → "services.openssh.enable"`.
26    pub services: BTreeMap<String, String>,
27    pub font_scale_option: String,
28    pub font_scales: Vec<String>,
29    pub keyboard_layout_options: Vec<String>,
30    pub keyboard_layouts: Vec<String>,
31    pub wifi_option: String,
32    pub wifi_networks: Vec<String>,
33    pub listen_addresses: BTreeMap<String, ListenAddress>,
34    /// Test-only entries for the gym (`gym:<name>`, e.g. `gym:kernel-param`):
35    /// set `option` to one of `permitted`, given as the argument `arg`. Ids
36    /// not starting with `gym:` are ignored.
37    pub gym_choices: BTreeMap<String, GymChoice>,
38}
39
40#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
41#[serde(rename_all = "camelCase", default)]
42pub struct GymChoice {
43    pub option: String,
44    pub arg: String,
45    pub permitted: Vec<String>,
46}
47
48#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
49pub enum ArgKind {
50    Bool,
51    OneOf(Vec<String>),
52}
53
54#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
55pub struct ArgSpec {
56    pub name: String,
57    pub kind: ArgKind,
58}
59
60#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
61pub struct Entry {
62    pub id: ChangeId,
63    pub description: String,
64    pub args: Vec<ArgSpec>,
65    /// A hint for the intent matcher and a test assertion; never a policy input.
66    pub affected_modules: Vec<String>,
67    /// Entries the diagnoser may use (§13: tagged `repair:`).
68    pub repair: bool,
69}
70
71#[derive(Clone, Debug, PartialEq, Eq)]
72pub enum CatalogError {
73    NotInCatalog(ChangeId),
74    MissingArg(String),
75    NotPermitted { arg: String, value: String },
76    WrongType(String),
77}
78
79impl std::fmt::Display for CatalogError {
80    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81        match self {
82            CatalogError::NotInCatalog(c) => write!(f, "no catalog entry {:?}", c.0),
83            CatalogError::MissingArg(a) => write!(f, "missing argument {a}"),
84            CatalogError::NotPermitted { arg, value } => write!(f, "{arg} = {value:?} is not a permitted value"),
85            CatalogError::WrongType(a) => write!(f, "argument {a} has the wrong type"),
86        }
87    }
88}
89
90fn one_of(name: &str, values: &[String]) -> ArgSpec {
91    ArgSpec { name: name.into(), kind: ArgKind::OneOf(values.to_vec()) }
92}
93
94pub fn entries(cfg: &CatalogConfig) -> Vec<Entry> {
95    let services: Vec<String> = cfg.services.keys().cloned().collect();
96    let mut out = vec![
97        Entry {
98            id: ChangeId("service-enable".into()),
99            description: "Turn a service on or off".into(),
100            args: vec![one_of("service", &services), ArgSpec { name: "enabled".into(), kind: ArgKind::Bool }],
101            affected_modules: cfg.services.values().cloned().collect(),
102            repair: false,
103        },
104        Entry {
105            id: ChangeId("repair:reenable-predicted".into()),
106            description: "Re-enable a service the manifest expects to be running".into(),
107            args: vec![one_of("service", &services)],
108            affected_modules: cfg.services.values().cloned().collect(),
109            repair: true,
110        },
111    ];
112    // §13 (P22): reapply a change committed earlier for the same target,
113    // whose assignments the override file no longer carries. Its arguments
114    // are the original change's id (`change`) and that change's arguments.
115    out.push(Entry {
116        id: ChangeId(REAPPLY.into()),
117        description: "Reapply a change committed earlier for the same unit".into(),
118        args: vec![one_of("change", &["service-enable".into(), "listen-address".into()])],
119        affected_modules: cfg
120            .services
121            .values()
122            .cloned()
123            .chain(cfg.listen_addresses.values().map(|l| l.option.clone()))
124            .collect(),
125        repair: true,
126    });
127    if !cfg.font_scale_option.is_empty() {
128        out.push(Entry {
129            id: ChangeId("font-scale".into()),
130            description: "Set the font scale for text".into(),
131            args: vec![one_of("scale", &cfg.font_scales)],
132            affected_modules: vec![cfg.font_scale_option.clone()],
133            repair: false,
134        });
135    }
136    if !cfg.keyboard_layout_options.is_empty() {
137        out.push(Entry {
138            id: ChangeId("keyboard-layout".into()),
139            description: "Set the keyboard layout".into(),
140            args: vec![one_of("layout", &cfg.keyboard_layouts)],
141            affected_modules: cfg.keyboard_layout_options.clone(),
142            repair: false,
143        });
144    }
145    if !cfg.wifi_option.is_empty() {
146        out.push(Entry {
147            id: ChangeId("wifi-select".into()),
148            description: "Choose the preferred Wi-Fi network".into(),
149            args: vec![one_of("ssid", &cfg.wifi_networks)],
150            affected_modules: vec![cfg.wifi_option.clone()],
151            repair: false,
152        });
153    }
154    if !cfg.listen_addresses.is_empty() {
155        out.push(Entry {
156            id: ChangeId("listen-address".into()),
157            description: "Set a service's listen address".into(),
158            args: vec![
159                one_of("service", &cfg.listen_addresses.keys().cloned().collect::<Vec<_>>()),
160                one_of("addr", &cfg.listen_addresses.values().flat_map(|l| l.permitted.clone()).collect::<Vec<_>>()),
161            ],
162            affected_modules: cfg.listen_addresses.values().map(|l| l.option.clone()).collect(),
163            repair: false,
164        });
165    }
166    for (id, c) in cfg.gym_choices.iter().filter(|(id, _)| id.starts_with("gym:")) {
167        out.push(Entry {
168            id: ChangeId(id.clone()),
169            description: format!("(gym) set {}", c.option),
170            args: vec![one_of(&c.arg, &c.permitted)],
171            affected_modules: vec![c.option.clone()],
172            repair: false,
173        });
174    }
175    out
176}
177
178fn arg_str<'a>(args: &'a BTreeMap<String, Value>, name: &str) -> Result<&'a str, CatalogError> {
179    match args.get(name) {
180        Some(Value::Str(s)) => Ok(s),
181        Some(_) => Err(CatalogError::WrongType(name.into())),
182        None => Err(CatalogError::MissingArg(name.into())),
183    }
184}
185
186fn permitted<'a>(name: &str, value: &'a str, allowed: &[String]) -> Result<&'a str, CatalogError> {
187    if allowed.iter().any(|a| a == value) {
188        Ok(value)
189    } else {
190        Err(CatalogError::NotPermitted { arg: name.into(), value: value.into() })
191    }
192}
193
194/// Run an entry's handler: validated option assignments to merge into the
195/// override file.
196pub fn handle(
197    cfg: &CatalogConfig,
198    change: &ChangeId,
199    args: &BTreeMap<String, Value>,
200) -> Result<OverrideSet, CatalogError> {
201    let mut a = BTreeMap::new();
202    match change.0.as_str() {
203        "service-enable" | "repair:reenable-predicted" => {
204            let services: Vec<String> = cfg.services.keys().cloned().collect();
205            let svc = permitted("service", arg_str(args, "service")?, &services)?;
206            let enabled = if change.0 == "service-enable" {
207                match args.get("enabled") {
208                    Some(Value::Bool(b)) => *b,
209                    Some(_) => return Err(CatalogError::WrongType("enabled".into())),
210                    None => return Err(CatalogError::MissingArg("enabled".into())),
211                }
212            } else {
213                true
214            };
215            a.insert(cfg.services[svc].clone(), Value::Bool(enabled));
216        }
217        "font-scale" if !cfg.font_scale_option.is_empty() => {
218            let scale = permitted("scale", arg_str(args, "scale")?, &cfg.font_scales)?;
219            a.insert(cfg.font_scale_option.clone(), Value::Str(scale.into()));
220        }
221        "keyboard-layout" if !cfg.keyboard_layout_options.is_empty() => {
222            let layout = permitted("layout", arg_str(args, "layout")?, &cfg.keyboard_layouts)?;
223            for opt in &cfg.keyboard_layout_options {
224                a.insert(opt.clone(), Value::Str(layout.into()));
225            }
226        }
227        "wifi-select" if !cfg.wifi_option.is_empty() => {
228            let ssid = permitted("ssid", arg_str(args, "ssid")?, &cfg.wifi_networks)?;
229            a.insert(cfg.wifi_option.clone(), Value::Str(ssid.into()));
230        }
231        "listen-address" => {
232            let svcs: Vec<String> = cfg.listen_addresses.keys().cloned().collect();
233            let svc = permitted("service", arg_str(args, "service")?, &svcs)?;
234            let la = &cfg.listen_addresses[svc];
235            let addr = permitted("addr", arg_str(args, "addr")?, &la.permitted)?;
236            a.insert(la.option.clone(), Value::Str(addr.into()));
237        }
238        id if id.starts_with("gym:") && cfg.gym_choices.contains_key(id) => {
239            let c = &cfg.gym_choices[id];
240            let v = permitted(&c.arg, arg_str(args, &c.arg)?, &c.permitted)?;
241            a.insert(c.option.clone(), Value::Str(v.into()));
242        }
243        REAPPLY => {
244            // Only the per-unit changes; never a repair of a repair.
245            let inner =
246                permitted("change", arg_str(args, "change")?, &["service-enable".into(), "listen-address".into()])?;
247            let mut rest = args.clone();
248            rest.remove("change");
249            return handle(cfg, &ChangeId(inner.into()), &rest);
250        }
251        _ => return Err(CatalogError::NotInCatalog(change.clone())),
252    }
253    Ok(OverrideSet { assignments: a })
254}
255
256/// The catalog id of the reapply repair (§13, P22).
257pub const REAPPLY: &str = "repair:reapply";
258
259/// The unit a per-unit catalog change is for (its `service` argument).
260pub fn target_of(change: &ChangeId, args: &BTreeMap<String, Value>) -> Option<planarian_schema::UnitId> {
261    match (change.0.as_str(), args.get("service")) {
262        ("service-enable" | "listen-address" | "repair:reenable-predicted", Some(Value::Str(s))) => {
263            Some(planarian_schema::UnitId::service(s))
264        }
265        _ => None,
266    }
267}
268
269/// Whether `live` still carries every assignment `set` makes.
270pub fn carried(live: &OverrideSet, set: &OverrideSet) -> bool {
271    set.assignments.iter().all(|(k, v)| live.assignments.get(k) == Some(v))
272}
273
274/// The `ApplyChange` that persists a runtime option (§12a write-back).
275pub fn writeback(path: &str, value: &Value) -> Option<(ChangeId, BTreeMap<String, Value>)> {
276    let (id, arg) = match path {
277        "font.scale" => ("font-scale", "scale"),
278        "keyboard.layout" => ("keyboard-layout", "layout"),
279        "wifi.network" => ("wifi-select", "ssid"),
280        _ => return None,
281    };
282    Some((ChangeId(id.into()), [(arg.to_string(), value.clone())].into()))
283}
284
285/// `base` with `change`'s assignments applied on top.
286pub fn merge(base: &OverrideSet, change: &OverrideSet) -> OverrideSet {
287    let mut a = base.assignments.clone();
288    a.extend(change.assignments.clone());
289    OverrideSet { assignments: a }
290}
291
292fn nix_string(s: &str) -> String {
293    let mut out = String::from("\"");
294    for c in s.chars() {
295        match c {
296            '"' => out.push_str("\\\""),
297            '\\' => out.push_str("\\\\"),
298            '$' => out.push_str("\\$"),
299            '\n' => out.push_str("\\n"),
300            c => out.push(c),
301        }
302    }
303    out.push('"');
304    out
305}
306
307fn nix_value(v: &Value) -> String {
308    match v {
309        Value::Bool(b) => b.to_string(),
310        Value::Int(i) => i.to_string(),
311        Value::Str(s) => nix_string(s),
312        Value::List(l) => format!("[ {} ]", l.iter().map(nix_value).collect::<Vec<_>>().join(" ")),
313    }
314}
315
316/// Option paths are dotted identifiers; quote any segment that isn't a plain one.
317fn nix_path(path: &str) -> String {
318    path.split('.')
319        .map(|seg| {
320            let plain = !seg.is_empty()
321                && seg.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '\'')
322                && !seg.starts_with(|c: char| c.is_ascii_digit() || c == '-' || c == '\'');
323            if plain { seg.to_string() } else { nix_string(seg) }
324        })
325        .collect::<Vec<_>>()
326        .join(".")
327}
328
329/// The full text of `planarian/overrides.nix`. Deterministic, so its content
330/// hash identifies the override set (§9 step 4).
331pub fn render(set: &OverrideSet) -> String {
332    let mut out = String::from(
333        "# Generated by Planarian. Do not edit: changes go through `planarian` / `ask`.\n\
334         # Each assignment is lib.mkOverride 90, so your own settings (priority 100)\n\
335         # lose to it, and lib.mkForce (priority 50) wins over it.\n\
336         { lib, ... }:\n{\n",
337    );
338    for (path, v) in &set.assignments {
339        out.push_str(&format!("  {} = lib.mkOverride 90 {};\n", nix_path(path), nix_value(v)));
340    }
341    out.push_str("}\n");
342    out
343}
344
345/// Parse a file produced by `render` back into assignments. Only the exact
346/// rendered form is accepted; anything else is `None`.
347pub fn parse(text: &str) -> Option<OverrideSet> {
348    let mut a = BTreeMap::new();
349    for line in text.lines() {
350        let line = line.trim();
351        let Some(rest) = line.strip_suffix(';') else { continue };
352        let Some((path, val)) = rest.split_once(" = lib.mkOverride 90 ") else { continue };
353        let path = path.split('.').map(|s| s.trim_matches('"').to_string()).collect::<Vec<_>>().join(".");
354        let v = if val == "true" || val == "false" {
355            Value::Bool(val == "true")
356        } else if let Ok(i) = val.parse::<i64>() {
357            Value::Int(i)
358        } else if val.starts_with('"') && val.ends_with('"') && val.len() >= 2 {
359            Value::Str(
360                val[1..val.len() - 1]
361                    .replace("\\\"", "\"")
362                    .replace("\\$", "$")
363                    .replace("\\n", "\n")
364                    .replace("\\\\", "\\"),
365            )
366        } else {
367            return None;
368        };
369        a.insert(path, v);
370    }
371    let set = OverrideSet { assignments: a };
372    (render(&set) == text).then_some(set)
373}
374
375#[cfg(test)]
376mod tests {
377    use super::*;
378
379    fn cfg() -> CatalogConfig {
380        CatalogConfig {
381            services: [
382                ("sshd".into(), "services.openssh.enable".into()),
383                ("avahi".into(), "services.avahi.enable".into()),
384            ]
385            .into(),
386            font_scale_option: "planarian.reference.fontScale".into(),
387            font_scales: ["0.75", "1", "1.25", "1.5", "2"].map(String::from).into(),
388            keyboard_layout_options: vec!["services.xserver.xkb.layout".into(), "console.keyMap".into()],
389            keyboard_layouts: vec!["us".into(), "de".into()],
390            wifi_option: "planarian.reference.wifiNetwork".into(),
391            wifi_networks: vec!["home".into()],
392            listen_addresses: [(
393                "gym-web".into(),
394                ListenAddress {
395                    option: "services.gym-web.listenAddress".into(),
396                    permitted: vec!["127.0.0.1".into(), "192.0.2.10".into()],
397                },
398            )]
399            .into(),
400            gym_choices: [
401                (
402                    "gym:kernel-param".into(),
403                    GymChoice {
404                        option: "gym.kernelParam".into(),
405                        arg: "param".into(),
406                        permitted: vec!["virtio_gpu.modeset=0".into()],
407                    },
408                ),
409                (
410                    "kernel-param".into(),
411                    GymChoice { option: "boot.x".into(), arg: "param".into(), permitted: vec!["y".into()] },
412                ),
413            ]
414            .into(),
415        }
416    }
417
418    fn args(kv: &[(&str, Value)]) -> BTreeMap<String, Value> {
419        kv.iter().map(|(k, v)| (k.to_string(), v.clone())).collect()
420    }
421
422    /// P22: `repair:reapply` is in the catalog, does what the change it
423    /// names does, and names only per-unit changes (never itself).
424    #[test]
425    fn reapply_does_what_the_original_did() {
426        let c = cfg();
427        assert!(entries(&c).iter().any(|e| e.id.0 == REAPPLY && e.repair));
428        let orig = args(&[("service", Value::Str("gym-web".into())), ("addr", Value::Str("192.0.2.10".into()))]);
429        let want = handle(&c, &ChangeId("listen-address".into()), &orig).unwrap();
430        let mut a = orig.clone();
431        a.insert("change".into(), Value::Str("listen-address".into()));
432        assert_eq!(handle(&c, &ChangeId(REAPPLY.into()), &a).unwrap(), want);
433        a.insert("change".into(), Value::Str(REAPPLY.into()));
434        assert!(handle(&c, &ChangeId(REAPPLY.into()), &a).is_err(), "not a repair of a repair");
435        a.insert("change".into(), Value::Str("font-scale".into()));
436        assert!(handle(&c, &ChangeId(REAPPLY.into()), &a).is_err(), "only per-unit changes");
437        assert_eq!(
438            target_of(&ChangeId("listen-address".into()), &orig),
439            Some(planarian_schema::UnitId::service("gym-web"))
440        );
441        assert!(carried(&want, &want) && !carried(&OverrideSet::default(), &want));
442    }
443
444    #[test]
445    fn handlers_validate_and_assign() {
446        let c = cfg();
447        let font = handle(&c, &ChangeId("font-scale".into()), &args(&[("scale", Value::Str("1.25".into()))])).unwrap();
448        assert_eq!(font.assignments["planarian.reference.fontScale"], Value::Str("1.25".into()));
449        assert_eq!(
450            handle(&c, &ChangeId("font-scale".into()), &args(&[("scale", Value::Str("9".into()))])),
451            Err(CatalogError::NotPermitted { arg: "scale".into(), value: "9".into() })
452        );
453        let off = handle(
454            &c,
455            &ChangeId("service-enable".into()),
456            &args(&[("service", Value::Str("sshd".into())), ("enabled", Value::Bool(false))]),
457        )
458        .unwrap();
459        assert_eq!(off.assignments["services.openssh.enable"], Value::Bool(false));
460        assert!(
461            handle(
462                &c,
463                &ChangeId("service-enable".into()),
464                &args(&[("service", Value::Str("planariad".into())), ("enabled", Value::Bool(false))])
465            )
466            .is_err(),
467            "not on the allowlist"
468        );
469        assert_eq!(
470            handle(&c, &ChangeId("manifest-probe-edit".into()), &BTreeMap::new()),
471            Err(CatalogError::NotInCatalog(ChangeId("manifest-probe-edit".into())))
472        );
473        let kb =
474            handle(&c, &ChangeId("keyboard-layout".into()), &args(&[("layout", Value::Str("de".into()))])).unwrap();
475        assert_eq!(kb.assignments.len(), 2);
476        let la = handle(
477            &c,
478            &ChangeId("listen-address".into()),
479            &args(&[("service", Value::Str("gym-web".into())), ("addr", Value::Str("192.0.2.10".into()))]),
480        )
481        .unwrap();
482        assert_eq!(la.assignments["services.gym-web.listenAddress"], Value::Str("192.0.2.10".into()));
483        assert_eq!(entries(&c).len(), 8, "the seven shipped kinds (repair:reapply since v4.6) and one gym: choice");
484    }
485
486    #[test]
487    fn render_parse_roundtrip() {
488        let set = OverrideSet {
489            assignments: [
490                ("services.openssh.enable".to_string(), Value::Bool(false)),
491                ("planarian.reference.fontScale".to_string(), Value::Str("1.25".into())),
492                ("services.gym-web.x".to_string(), Value::Str("a \"q\" $x".into())),
493                ("boot.n".to_string(), Value::Int(3)),
494            ]
495            .into(),
496        };
497        let text = render(&set);
498        assert!(text.contains("services.openssh.enable = lib.mkOverride 90 false;"));
499        assert!(text.contains("\\$x"), "no antiquotation injection");
500        let back = parse(&text).unwrap();
501        assert_eq!(render(&back), text);
502        assert!(parse("{ lib, ... }: { x = import /etc/evil; }").is_none());
503        assert_eq!(parse(&render(&OverrideSet::default())), Some(OverrideSet::default()));
504    }
505
506    #[test]
507    fn gym_choices_only_with_the_prefix() {
508        let c = cfg();
509        let ids: Vec<String> = entries(&c).into_iter().map(|e| e.id.0).collect();
510        assert!(ids.contains(&"gym:kernel-param".to_string()));
511        assert!(!ids.contains(&"kernel-param".to_string()), "a choice without gym: is not an entry");
512        let args = [("param".to_string(), Value::Str("virtio_gpu.modeset=0".into()))].into();
513        let set = handle(&c, &ChangeId("gym:kernel-param".into()), &args).unwrap();
514        assert_eq!(set.assignments["gym.kernelParam"], Value::Str("virtio_gpu.modeset=0".into()));
515        let bad = [("param".to_string(), Value::Str("init=/bin/sh".into()))].into();
516        assert!(matches!(
517            handle(&c, &ChangeId("gym:kernel-param".into()), &bad),
518            Err(CatalogError::NotPermitted { .. })
519        ));
520        assert!(matches!(handle(&c, &ChangeId("kernel-param".into()), &args), Err(CatalogError::NotInCatalog(_))));
521    }
522}
523
524#[cfg(test)]
525mod golden {
526    /// The flake template ships an empty override file; the parser only accepts
527    /// the exact rendered form, so the two must stay identical.
528    #[test]
529    fn template_override_file_is_the_rendered_empty_set() {
530        let shipped = include_str!("../../../nix/template/planarian/overrides.nix");
531        assert_eq!(shipped, super::render(&planarian_schema::OverrideSet::default()));
532    }
533}