Skip to main content

planarian_daemon/
approval.rs

1//! §16 *Approvals show the change* (owner decision, Nov 2): the
2//! configuration a generation transition that asks would apply. The core
3//! builds it from the derived facts, which come from the evaluated
4//! generations (§11), never from the proposal. `planarian approve`, the
5//! console and the notice show it; `approve --json` and IPC
6//! `Pending.diff` carry the same data. A diff that differs when the facts
7//! are re-derived at approval changes the token's `bound_to` (the binding
8//! hashes the facts), so the approval does not apply and the change asks
9//! again.
10
11use planarian_ipc::{ApprovalDiff, OptionChange, RemovedUnit, sensitivity_kinds};
12use planarian_policy::Tables;
13use planarian_schema::*;
14
15/// Said when the evaluated generations could not be read, so the options
16/// shown are the requested override settings only.
17pub const UNAVAILABLE: &str = "evaluated options unavailable; showing the requested settings";
18
19/// Said when the facts carry no sensitivity per removed unit (a record
20/// journaled before `removes_each`), so each shows the change's union.
21pub const EACH_UNAVAILABLE: &str = "each removed unit's own sensitivity unavailable; showing the change's";
22
23/// The removed units with their sensitivities: `removes_each[i]` for
24/// `removes[i]` when the two are the same length, otherwise the union
25/// `removes_sensitivity` for each, and `false` (the display says so).
26pub 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
34/// Shown first: what a person most needs to see before approving.
35fn first(s: &Sensitivities) -> bool {
36    s.contains(Sensitivity::Security) || s.contains(Sensitivity::External)
37}
38
39/// The diff a generation transition would apply; empty for any other.
40/// `config_diff` and `removed` (the units it would remove or stop, from
41/// [`removed`]) come from the derived facts.
42pub 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    // Each removed unit's sensitivity as derive computed it, the lookup the
69    // decision used (§16: shown as decided).
70    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    // Never present the smaller list as the whole change.
80    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    /// Every option with old, new and sensitivity; Security and External
112    /// first, then by path; removed units with their sensitivity.
113    #[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    /// A removed Security user template shows `[Security]`: its sensitivity
141    /// is derive's, the decision's own lookup, not a `defs` lookup that
142    /// misses templates (#37 review).
143    #[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    /// When the evaluated generations could not be read, the display says so
158    /// rather than present the requested settings as the whole change.
159    #[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    /// A transition that is not a generation one shows no diff.
170    #[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    /// `DerivedFacts` as main journals it (since 4bb9c99): `removes` a list
179    /// of unit ids and no `removes_each`. A literal, not a round-trip, so a
180    /// change to the field's form fails here (#37 review: an undecodable
181    /// record is truncated, or Integrity).
182    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        // Same length: each its own.
205        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    /// A whole `Prepared` record as main journals it, `removes` non-empty
215    /// (fixtures/prepared-before-removes-each.json: a Rollback that removes
216    /// sshd, written by this tree with `removes_each` taken out).
217    #[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}