1use planarian_ipc::{ApprovalDiff, OptionChange, RemovedUnit, sensitivity_kinds};
12use planarian_policy::Tables;
13use planarian_schema::*;
14
15pub const UNAVAILABLE: &str = "evaluated options unavailable; showing the requested settings";
18
19pub const EACH_UNAVAILABLE: &str = "each removed unit's own sensitivity unavailable; showing the change's";
22
23pub fn removed(f: &DerivedFacts) -> (Vec<(UnitId, Sensitivities)>, bool) {
27 if f.removes_each.len() == f.removes.len() {
28 (f.removes.iter().cloned().zip(f.removes_each.iter().copied()).collect(), true)
29 } else {
30 (f.removes.iter().map(|u| (u.clone(), f.removes_sensitivity)).collect(), false)
31 }
32}
33
34fn first(s: &Sensitivities) -> bool {
36 s.contains(Sensitivity::Security) || s.contains(Sensitivity::External)
37}
38
39pub fn diff(
43 t: &Transition,
44 config_diff: Option<&ConfigDiff>,
45 evaluated: bool,
46 removed: &[(UnitId, Sensitivities)],
47 each_known: bool,
48 tables: &Tables,
49) -> ApprovalDiff {
50 if !t.changes_generation() {
51 return ApprovalDiff::default();
52 }
53 let mut options: Vec<OptionChange> = config_diff
54 .into_iter()
55 .flat_map(|d| d.options.entries.iter())
56 .map(|(path, (old, new))| {
57 let sensitivity = tables.option_sensitivity(path);
58 OptionChange {
59 path: path.clone(),
60 old: old.clone(),
61 new: new.clone(),
62 kinds: sensitivity_kinds(&sensitivity),
63 sensitivity,
64 }
65 })
66 .collect();
67 options.sort_by(|a, b| (!first(&a.sensitivity), &a.path).cmp(&(!first(&b.sensitivity), &b.path)));
68 let mut removes: Vec<RemovedUnit> = removed
71 .iter()
72 .map(|(u, sensitivity)| RemovedUnit {
73 unit: u.clone(),
74 kinds: sensitivity_kinds(sensitivity),
75 sensitivity: *sensitivity,
76 })
77 .collect();
78 removes.sort_by(|a, b| (!first(&a.sensitivity), &a.unit).cmp(&(!first(&b.sensitivity), &b.unit)));
79 let notes: Vec<&str> =
81 [(!evaluated).then_some(UNAVAILABLE), (!each_known && !removes.is_empty()).then_some(EACH_UNAVAILABLE)]
82 .into_iter()
83 .flatten()
84 .collect();
85 let note = (!notes.is_empty()).then(|| notes.join("; "));
86 ApprovalDiff { options, removes, note }
87}
88
89#[cfg(test)]
90mod tests {
91 use super::*;
92
93 fn tables() -> Tables {
94 Tables {
95 option_sensitivity: vec![
96 ("services.openssh".into(), Sensitivities::of(&[Sensitivity::Security])),
97 ("networking.wireguard".into(), Sensitivities::of(&[Sensitivity::External])),
98 ],
99 ..Default::default()
100 }
101 }
102
103 fn facts(opts: &[(&str, Option<Value>, Option<Value>)]) -> ConfigDiff {
104 let mut d = ConfigDiff::default();
105 for (p, o, n) in opts {
106 d.options.insert(p.to_string(), (o.clone(), n.clone()));
107 }
108 d
109 }
110
111 #[test]
114 fn the_diff_puts_security_and_external_first() {
115 let f = facts(&[
116 ("a.font.scale", Some(Value::Str("1".into())), Some(Value::Str("1.25".into()))),
117 ("services.openssh.enable", Some(Value::Bool(true)), Some(Value::Bool(false))),
118 ("networking.wireguard.interfaces.wg0", None, Some(Value::Str("on".into()))),
119 ]);
120 let sshd = UnitId::service("sshd");
121 let removed = [(sshd.clone(), Sensitivities::of(&[Sensitivity::Security]))];
122 let d = diff(&Transition::Rollback, Some(&f), true, &removed, true, &tables());
123 let paths: Vec<&str> = d.options.iter().map(|o| o.path.as_str()).collect();
124 assert_eq!(paths, ["networking.wireguard.interfaces.wg0", "services.openssh.enable", "a.font.scale"]);
125 assert!(d.options[1].sensitivity.contains(Sensitivity::Security));
126 assert_eq!(d.options[1].old, Some(Value::Bool(true)));
127 assert_eq!(d.options[1].new, Some(Value::Bool(false)));
128 assert_eq!(d.removes.len(), 1);
129 assert_eq!(d.removes[0].unit, sshd);
130 assert_eq!(d.removes[0].kinds, ["Security"]);
131 let l = d.lines();
132 assert_eq!(l[0], "networking.wireguard.interfaces.wg0 added: on [External]");
133 assert_eq!(l[1], "services.openssh.enable: true → false [Security]");
134 assert_eq!(l[3], "removes or stops service:sshd [Security]");
135 let s = d.summary().unwrap();
136 assert!(s.starts_with("changes 3 options (2 Security/External:"), "{s}");
137 assert!(s.ends_with("removes or stops service:sshd"), "{s}");
138 }
139
140 #[test]
144 fn a_removed_security_user_template_shows_security() {
145 let tpl: UnitId = "service@0:user-vpn".parse().unwrap();
146 let d = diff(
147 &Transition::Rollback,
148 None,
149 true,
150 &[(tpl, Sensitivities::of(&[Sensitivity::Security]))],
151 true,
152 &tables(),
153 );
154 assert_eq!(d.lines(), ["removes or stops service@0:user-vpn [Security]"]);
155 }
156
157 #[test]
160 fn an_unreadable_evaluation_is_said() {
161 let f = facts(&[("a.font.scale", Some(Value::Str("1".into())), Some(Value::Str("1.25".into())))]);
162 let d = diff(&Transition::Rollback, Some(&f), false, &[], true, &tables());
163 assert_eq!(d.note.as_deref(), Some(UNAVAILABLE));
164 assert_eq!(d.lines()[0], UNAVAILABLE);
165 assert!(d.summary().unwrap().contains(UNAVAILABLE));
166 assert!(diff(&Transition::Rollback, Some(&f), true, &[], true, &tables()).note.is_none());
167 }
168
169 #[test]
171 fn only_generation_transitions_have_a_diff() {
172 let f = facts(&[("services.openssh.enable", Some(Value::Bool(true)), Some(Value::Bool(false)))]);
173 let d = diff(&Transition::RestartUnit(UnitId::service("sshd")), Some(&f), true, &[], true, &tables());
174 assert!(d.is_empty());
175 assert_eq!(d.summary(), None);
176 }
177
178 const FACTS_BEFORE_REMOVES_EACH: &str = r#"{"affected_units":["service:sshd"],"sensitivity":["Security"],
183 "disruption":{"brief":true,"session_loss":false,"data_interruption":false,"connectivity_loss":false},
184 "recovery":"Revertible","touches_manifest":true,"touches_canonical_probe":false,"changes_desired":true,
185 "touches_core":false,"below_security_floor":false,"other_sessions_affected":false,"affected_sessions":[],
186 "restores_unattributed_stop":false,"restarts_failed_unit":false,"removes_sensitivity":["Security"],
187 "removes":["service:sshd","service:gym-web"],"restarts_held_unit":false,
188 "caller":{"User":{"uid":0,"session":null,"seat":null,"active":false}},
189 "config_diff":{"options":{"services.openssh.enable":[true,false]}}}"#;
190
191 #[test]
192 fn facts_journaled_before_removes_each_decode_and_show_the_union() {
193 let f: DerivedFacts = serde_json::from_str(FACTS_BEFORE_REMOVES_EACH).expect("main's form decodes");
194 let (sshd, web) = (UnitId::service("sshd"), UnitId::service("gym-web"));
195 assert_eq!(f.removes, [sshd.clone(), web.clone()]);
196 assert!(f.removes_each.is_empty());
197 let (units, each_known) = super::removed(&f);
198 assert!(!each_known);
199 let d = diff(&Transition::Rollback, f.config_diff.as_ref(), true, &units, each_known, &tables());
200 assert_eq!(d.note.as_deref(), Some(EACH_UNAVAILABLE), "it says so");
201 let l = d.lines();
202 assert!(l.contains(&"removes or stops service:sshd [Security]".to_string()), "{l:?}");
203 assert!(l.contains(&"removes or stops service:gym-web [Security]".to_string()), "the union for each: {l:?}");
204 let mut f = f;
206 f.removes_each = vec![Sensitivities::of(&[Sensitivity::Security]), Sensitivities::default()];
207 let (units, each_known) = super::removed(&f);
208 assert!(each_known);
209 assert_eq!(units, [(sshd, f.removes_each[0]), (web, Sensitivities::default())]);
210 let d = diff(&Transition::Rollback, None, true, &units, each_known, &tables());
211 assert!(d.note.is_none());
212 }
213
214 #[test]
218 fn a_prepared_record_journaled_before_removes_each_decodes() {
219 let raw = include_str!("fixtures/prepared-before-removes-each.json");
220 assert!(!raw.contains("removes_each"), "the fixture is main's form");
221 let r: planarian_txn::ledger::Record = serde_json::from_str(raw).expect("main's record decodes");
222 let planarian_txn::ledger::Record::Prepared(t) = r else { panic!("a Prepared record") };
223 assert_eq!(t.facts.removes, [UnitId::service("sshd")]);
224 assert!(t.facts.removes_each.is_empty());
225 assert!(t.facts.removes_sensitivity.security);
226 }
227}