1use crate::host::changed_units;
5use planarian_policy::Tables;
6use planarian_schema::spec::dependents;
7use planarian_schema::*;
8use planarian_state::Canonical;
9use std::collections::{BTreeMap, BTreeSet};
10
11pub const CORE_UNITS: [&str; 2] = ["planariad", "planarian-recovery"];
12
13pub struct DeriveInput<'a> {
14 pub transition: &'a Transition,
15 pub defs: &'a UnitDefs,
16 pub base: &'a EvaluatedGeneration,
17 pub candidate: Option<&'a EvaluatedGeneration>,
18 pub activation: Option<&'a ActivationPlan>,
19 pub base_overrides: &'a OverrideSet,
20 pub candidate_overrides: Option<&'a OverrideSet>,
21 pub caller: Actor,
22 pub tables: &'a Tables,
23 pub canonical: &'a Canonical,
24 pub state: &'a SystemState,
27}
28
29pub fn manifest_of(e: &EvaluatedGeneration) -> Manifest {
30 Manifest { units: e.units.clone(), user_units: e.user_units.clone() }
31}
32
33fn already_failed(s: &SystemState, defs: &UnitDefs, u: &UnitId) -> bool {
42 let Some(d) = defs.get(u) else { return false };
43 d.desired.status == DesiredStatus::Running
44 && s.expected(u, defs)
45 && matches!(s.status(u, defs), UnitStatus::Failed(_) | UnitStatus::Stopped | UnitStatus::Absent)
46}
47
48fn hold_in_place(s: &SystemState, u: &UnitId) -> bool {
56 s.holds.contains(u)
57}
58
59fn unit_facts<'a>(
60 units: impl IntoIterator<Item = &'a UnitId>,
61 defs: &UnitDefs,
62 services: &BTreeMap<String, Sensitivities>,
63 already_failed: impl Fn(&UnitId) -> bool,
64) -> (Sensitivities, Disruptions) {
65 let mut sens = Sensitivities::default();
66 let mut disruption = Disruptions::default();
69 for u in units {
70 let unknown = Sensitivities::of(&[Sensitivity::External]);
74 let empty = |s: &Sensitivities| !(s.routine || s.security || s.user_data || s.external || s.core);
75 match defs.get(u) {
76 Some(d) => {
77 sens = sens.union(if empty(&d.sensitivity) { &unknown } else { &d.sensitivity });
78 if !already_failed(u) {
82 disruption.insert(d.restart_disruption);
83 }
84 }
85 None if matches!(u.scope, Scope::User { .. }) => {}
88 None => match services.get(&u.name) {
91 Some(t) if !empty(t) => sens = sens.union(t),
92 _ => sens = sens.union(&unknown),
93 },
94 }
95 if CORE_UNITS.contains(&u.name.as_str()) {
96 sens.insert(Sensitivity::Core);
97 }
98 }
99 (sens, disruption)
100}
101
102pub fn diff(base: &OverrideSet, cand: &OverrideSet) -> ConfigDiff {
103 let mut d = ConfigDiff::default();
104 for (k, v) in &cand.assignments {
105 if base.assignments.get(k) != Some(v) {
106 d.options.insert(k.clone(), (base.assignments.get(k).cloned(), Some(v.clone())));
107 }
108 }
109 for (k, v) in &base.assignments {
110 if !cand.assignments.contains_key(k) {
111 d.options.insert(k.clone(), (Some(v.clone()), None));
112 }
113 }
114 d
115}
116
117pub fn option_diff(base: &BTreeMap<String, Value>, cand: &BTreeMap<String, Value>) -> ConfigDiff {
119 let mut d = ConfigDiff::default();
120 for (k, v) in cand {
121 if base.get(k) != Some(v) {
122 d.options.insert(k.clone(), (base.get(k).cloned(), Some(v.clone())));
123 }
124 }
125 for (k, v) in base {
126 if !cand.contains_key(k) {
127 d.options.insert(k.clone(), (Some(v.clone()), None));
128 }
129 }
130 d
131}
132
133fn touches_canonical(base: &Manifest, cand: &Manifest, canonical: &Canonical) -> bool {
136 for (id, d) in cand.units.iter().chain(cand.user_units.iter().map(|u| (&u.id, u))) {
137 let Some(core) = canonical.get(&id.name) else { continue };
138 for p in &d.probes {
139 if let Some(c) = core.iter().find(|c| c.id == p.id)
140 && c != p
141 {
142 return true;
143 }
144 }
145 if let Some(b) = base.units.get(id) {
148 let declared = |x: &UnitDef| x.probes.iter().filter(|p| core.iter().any(|c| c.id == p.id)).count();
149 if declared(b) > declared(d) {
150 return true;
151 }
152 }
153 }
154 false
155}
156
157fn touches_manifest(base: &Manifest, cand: &Manifest, diff: &ConfigDiff) -> bool {
166 if diff.options.keys().any(|k| k.starts_with("services.planarian.")) {
167 return true;
168 }
169 let entry = |d: &UnitDef| {
172 let mut d = crate::host::declared(d);
173 d.probes.retain(|p| p.origin == ProbeOrigin::User);
174 d
175 };
176 let entries = |m: &Manifest| -> (BTreeMap<UnitId, UnitDef>, BTreeMap<UnitId, UnitDef>) {
177 (
178 m.units.iter().map(|(id, d)| (id.clone(), entry(d))).collect(),
179 m.user_units.iter().map(|d| (d.id.clone(), entry(d))).collect(),
180 )
181 };
182 entries(base) != entries(cand)
183}
184
185fn changes_desired(base: &Manifest, cand: &Manifest) -> bool {
186 base.units.keys().ne(cand.units.keys())
187 || base.units.iter().any(|(id, b)| cand.units.get(id).is_some_and(|c| c.desired != b.desired))
188}
189
190pub(crate) fn removed_units(
197 base_m: &Manifest,
198 cand_m: &Manifest,
199 activation: Option<&ActivationPlan>,
200 defs: &UnitDefs,
201 services: &BTreeMap<String, Sensitivities>,
202) -> (Vec<UnitId>, Vec<Sensitivities>, Sensitivities) {
203 let mut removed: Vec<UnitId> = base_m.units.keys().filter(|u| !cand_m.units.contains_key(*u)).cloned().collect();
204 removed.extend(
205 base_m.user_units.iter().filter(|b| !cand_m.user_units.iter().any(|c| c.id == b.id)).map(|b| b.id.clone()),
206 );
207 if let Some(a) = activation {
208 removed.extend(a.stop.iter().cloned());
209 }
210 let mut known = defs.clone();
211 for b in &base_m.user_units {
212 known.entry(b.id.clone()).or_insert_with(|| b.clone());
213 }
214 let sens = unit_facts(&removed, &known, services, |_| false).0;
215 let removed = unit_set(removed);
218 let each = removed.iter().map(|u| unit_facts(std::iter::once(u), &known, services, |_| false).0).collect();
219 (removed, each, sens)
220}
221
222pub fn derive(i: &DeriveInput<'_>) -> DerivedFacts {
223 let base_m = manifest_of(i.base);
224 let one = |u: &UnitId| {
225 let mut s = dependents(u, i.defs);
226 s.insert(u.clone());
227 s
228 };
229 let mut f = DerivedFacts {
230 affected_units: Vec::new(),
231 sensitivity: Sensitivities::of(&[Sensitivity::Routine]),
232 disruption: Disruptions::default(),
233 recovery: Recovery::Idempotent,
234 touches_manifest: false,
235 touches_canonical_probe: false,
236 changes_desired: false,
237 touches_core: false,
238 below_security_floor: false,
239 restores_unattributed_stop: false,
240 restarts_failed_unit: false,
241 removes_sensitivity: Default::default(),
242 removes: Default::default(),
243 removes_each: Default::default(),
244 restarts_held_unit: false,
245 other_sessions_affected: false,
246 affected_sessions: Vec::new(),
247 caller: i.caller.clone(),
248 config_diff: None,
249 };
250 let unit_op = |f: &mut DerivedFacts, u: &UnitId, recovery: Recovery| {
251 f.affected_units = unit_set(one(u));
252 let (s, d) = unit_facts(&f.affected_units, i.defs, &i.base.services, |u| already_failed(i.state, i.defs, u));
253 f.sensitivity = s;
254 f.disruption = d;
255 f.recovery = recovery;
256 f.touches_core = f.sensitivity.contains(Sensitivity::Core);
257 };
258 match i.transition {
259 Transition::RestartUnit(u) => {
260 unit_op(&mut f, u, Recovery::Idempotent);
261 f.restarts_failed_unit = already_failed(i.state, i.defs, u);
266 f.restarts_held_unit = f.restarts_failed_unit && hold_in_place(i.state, u);
270 }
271 Transition::StopUnit(u) | Transition::StopAndSuppress { unit: u, .. } => {
272 unit_op(&mut f, u, Recovery::Revertible)
273 }
274 Transition::SetOverride(s) => match s.desired {
279 OverrideDesired::LeaveAlone => {
280 unit_op(&mut f, &s.unit, Recovery::Idempotent);
281 f.affected_units = vec![s.unit.clone()];
282 f.disruption = Disruptions::default();
283 }
284 OverrideDesired::Stopped | OverrideDesired::Absent | OverrideDesired::Detected => {
285 unit_op(&mut f, &s.unit, Recovery::Revertible)
286 }
287 },
288 Transition::LiftOverride(u) => {
289 unit_op(&mut f, u, Recovery::Idempotent);
290 f.affected_units = vec![u.clone()];
291 f.disruption = Disruptions::default();
292 }
293 Transition::BindDriver { device, .. } => unit_op(&mut f, device, Recovery::Revertible),
294 Transition::SetRuntimeOption { path, .. } => {
295 f.sensitivity = i.tables.option_sensitivity(&format!("runtime.{path}"));
296 let providers: Vec<&UnitId> =
303 i.defs.values().filter(|d| d.runtime_repair.as_deref() == Some(path.as_str())).map(|d| &d.id).collect();
304 let provider_failed = !providers.is_empty() && providers.iter().all(|u| already_failed(i.state, i.defs, u));
305 f.disruption = if path == "wifi.network" && !provider_failed {
306 Disruptions::of(Disruption::ConnectivityLoss)
307 } else {
308 Disruptions::default()
309 };
310 f.recovery = Recovery::Revertible;
311 }
312 Transition::CollectGarbage { .. } | Transition::Resolve { .. } => {}
313 Transition::RestoreSnapshot(_) => {
314 f.recovery = Recovery::NonReversible;
315 f.sensitivity = Sensitivities::of(&[Sensitivity::UserData]);
316 }
317 Transition::ApplyChange { .. }
318 | Transition::SwitchGeneration(_)
319 | Transition::Revert { .. }
320 | Transition::Rollback
321 | Transition::LoadModel(_) => {
322 f.recovery = Recovery::Revertible;
323 let Some(cand) = i.candidate else { return f };
324 let cand_m = manifest_of(cand);
325 let mut affected = changed_units(&base_m, &cand_m);
326 if let Some(a) = i.activation {
327 affected.extend(a.start.iter().chain(&a.stop).chain(&a.restart).chain(&a.reload).cloned());
328 }
329 let mut config = i.candidate_overrides.map(|c| diff(i.base_overrides, c)).unwrap_or_default();
330 if matches!(
336 i.transition,
337 Transition::SwitchGeneration(_) | Transition::Revert { .. } | Transition::Rollback
338 ) {
339 for (k, v) in option_diff(&i.base.options, &cand.options).options {
340 if !config.options.contains_key(&k) {
341 config.options.insert(k, v);
342 }
343 }
344 }
345 let mut all_defs = i.defs.clone();
346 for (k, v) in &cand.units {
347 all_defs.entry(k.clone()).or_insert_with(|| v.clone());
348 }
349 let mut services = i.base.services.clone();
350 services.extend(cand.services.iter().map(|(k, v)| (k.clone(), *v)));
351 let (s, d) = unit_facts(&affected, &all_defs, &services, |u| already_failed(i.state, i.defs, u));
357 f.sensitivity = s.union(&i.tables.diff_sensitivity(&config));
358 f.disruption = d;
359 f.touches_manifest = touches_manifest(&base_m, &cand_m, &config);
360 f.touches_canonical_probe = touches_canonical(&base_m, &cand_m, i.canonical);
361 f.changes_desired = changes_desired(&base_m, &cand_m);
362 f.touches_core = cand.core_units != i.base.core_units || f.sensitivity.contains(Sensitivity::Core);
363 if let Transition::ApplyChange { change, args } = i.transition
367 && unexplained(i.base, cand, change, args)
368 {
369 f.sensitivity = f.sensitivity.union(&Sensitivities::of(&[Sensitivity::Security]));
370 f.touches_manifest = true;
371 }
372 let (removes, each, removes_sensitivity) =
373 removed_units(&base_m, &cand_m, i.activation, &all_defs, &services);
374 f.removes_sensitivity = removes_sensitivity;
375 f.removes = removes;
376 f.removes_each = each;
377 f.affected_units = unit_set(affected);
378 f.config_diff = (!config.options.is_empty()).then_some(config);
379 }
380 }
381 f
382}
383
384pub fn unexplained(
389 base: &EvaluatedGeneration,
390 cand: &EvaluatedGeneration,
391 change: &ChangeId,
392 args: &BTreeMap<String, Value>,
393) -> bool {
394 let named = (change.0 == "service-enable").then(|| match args.get("service") {
395 Some(Value::Str(s)) => s.clone(),
396 _ => String::new(),
397 });
398 let services = |e: &EvaluatedGeneration| -> BTreeSet<String> {
399 e.declarations.keys().filter(|k| Some(*k) != named.as_ref()).cloned().collect()
400 };
401 let declared = !base.declarations.is_empty() && services(base) != services(cand);
403 declared || base.inputs != cand.inputs || !cand.insecure.is_subset(&base.insecure)
406}
407
408pub fn session_facts(f: &mut DerivedFacts, sessions: &[SessionInfo]) {
414 let live: Vec<&SessionInfo> = sessions.iter().filter(|s| s.establishes()).collect();
415 let disruptive = f.disruption.session_loss || f.disruption.connectivity_loss || f.disruption.data_interruption;
416 let mut hit: BTreeSet<String> = BTreeSet::new();
417 let mut system = false;
418 for u in &f.affected_units {
419 match &u.scope {
420 Scope::User { uid, session: Some(s) } if live.iter().any(|x| x.uid == *uid && &x.id == s) => {
421 hit.insert(s.clone());
422 }
423 Scope::User { uid, .. } => hit.extend(live.iter().filter(|x| x.uid == *uid).map(|x| x.id.clone())),
424 Scope::System => system = true,
425 }
426 }
427 if system && disruptive {
428 hit.extend(live.iter().map(|x| x.id.clone()));
429 }
430 let caller = f.caller.uid();
431 f.other_sessions_affected = system
432 && (f.disruption.session_loss || f.disruption.connectivity_loss)
433 && live.iter().any(|x| Some(x.uid) != caller);
434 f.affected_sessions = hit.into_iter().collect();
435}
436
437pub fn stop_fact(f: &mut DerivedFacts, t: &Transition, s: &SystemState, defs: &UnitDefs, ended: Option<Ending>) {
442 let Transition::RestartUnit(u) = t else { return };
443 let stopped = match (s.units.get(u).map(|(o, _)| o), ended) {
449 (Some(Observed::Bound), _) => true,
450 (Some(Observed::Failed(_)), Some(e)) => e.is_termination(),
451 _ => false,
452 };
453 f.restores_unattributed_stop = stopped
454 && s.override_of(u).is_none()
455 && s.expected(u, defs)
456 && defs.get(u).is_some_and(|d| d.desired.status == DesiredStatus::Running);
457}
458
459pub fn floor_fact(f: &mut DerivedFacts, t: &Transition, s: &SystemState) {
462 let Some(floor) = s.security_floor else { return };
463 let target = match t {
464 Transition::SwitchGeneration(g) | Transition::Revert { to: g } => Some(*g),
465 Transition::Rollback => Some(s.known_good),
466 _ => None,
467 };
468 f.below_security_floor = target.is_some_and(|g| g.0 < floor.0);
469}
470
471#[cfg(test)]
472mod sensitivity_completeness {
473 use super::*;
474
475 #[test]
478 fn unknown_units_fail_closed() {
479 let mut defs = UnitDefs::new();
480 let mut empty = planarian_fake::fixtures::gym_units()[&UnitId::service("gym-web")].clone();
481 empty.id = UnitId::service("gym-untagged");
482 empty.sensitivity = Sensitivities::default();
483 defs.insert(empty.id.clone(), empty.clone());
484 let none = BTreeMap::new();
485 let (s, _) = unit_facts([&empty.id], &defs, &none, |_| false);
486 assert!(s.external && !s.routine, "an empty sensitivity is External: {s:?}");
487 let ghost = UnitId::service("not-in-the-manifest");
488 let (s, _) = unit_facts([&ghost], &defs, &none, |_| false);
489 assert!(s.external && !s.routine, "an undeclared system unit is External: {s:?}");
490 let tables = BTreeMap::from([
492 ("dbus-broker".to_string(), Sensitivities::of(&[Sensitivity::Routine])),
493 ("blank".to_string(), Sensitivities::default()),
494 ]);
495 let (s, _) = unit_facts([&UnitId::service("dbus-broker")], &defs, &tables, |_| false);
496 assert!(s.routine && !s.external, "a defined service takes its table entry: {s:?}");
497 let (s, _) = unit_facts([&UnitId::service("blank")], &defs, &tables, |_| false);
498 assert!(s.external, "an empty table entry still fails closed: {s:?}");
499 let own =
500 UnitId { kind: UnitKind::Service, name: "notes".into(), scope: Scope::User { uid: 1000, session: None } };
501 let (s, _) = unit_facts([&own], &defs, &none, |_| false);
502 assert!(!s.external, "an undeclared user unit stays as it was: {s:?}");
503 }
504
505 #[test]
510 fn removed_units_carry_their_sensitivity() {
511 let mut vpn = planarian_fake::fixtures::gym_user_units()[0].clone();
512 vpn.id.name = "user-vpn".into();
513 vpn.sensitivity = Sensitivities::of(&[Sensitivity::Security]);
514 let base = Manifest { units: planarian_fake::fixtures::gym_units(), user_units: vec![vpn.clone()] };
515 let mut cand = base.clone();
516 cand.user_units.clear();
517 let none = BTreeMap::new();
518 let (removed, each, s) = removed_units(&base, &cand, None, &UnitDefs::new(), &none);
519 assert_eq!(removed, vec![vpn.id.clone()]);
520 assert_eq!(each.len(), removed.len(), "parallel");
521 assert!(each[0].security, "each carries its own: {each:?}");
522 assert!(s.security, "the removed user template is Security: {s:?}");
523 let (removed, each, s) = removed_units(&base, &base, None, &base.units, &none);
525 assert!(removed.is_empty() && each.is_empty() && s == Sensitivities::default(), "{removed:?} {s:?}");
526 let web = UnitId::service("gym-web");
528 let plan = ActivationPlan { stop: [web.clone()].into(), ..Default::default() };
529 let (removed, each, _) = removed_units(&base, &base, Some(&plan), &base.units, &none);
530 assert_eq!(removed, vec![web]);
531 assert_eq!(each.len(), 1);
532 }
533}
534
535#[cfg(test)]
537#[path = "derive_c19_tests.rs"]
538mod c19;
539
540#[cfg(test)]
542#[path = "derive_props.rs"]
543mod props;