1use 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 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 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 pub affected_modules: Vec<String>,
67 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 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
194pub 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 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
256pub const REAPPLY: &str = "repair:reapply";
258
259pub 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
269pub fn carried(live: &OverrideSet, set: &OverrideSet) -> bool {
271 set.assignments.iter().all(|(k, v)| live.assignments.get(k) == Some(v))
272}
273
274pub 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
285pub 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
316fn 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
329pub 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
345pub 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 #[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 #[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}