Skip to main content

planarian_schema/
transition.rs

1//! Transitions, decisions, derived facts and proposals (§8).
2
3use crate::ids::{ChangeId, GenerationId, Hash, IncidentId, Millis, ModelId, SnapshotId, StorePath, TxnId};
4use crate::pathmap::PathMap;
5use crate::state::{SystemState, UnitStatus};
6use crate::unit::{Actor, Disruption, Override, Recovery, Sensitivities, UnitDefs, UnitId, Until};
7use serde::{Deserialize, Serialize};
8use std::collections::{BTreeMap, BTreeSet};
9use vstd::prelude::*;
10
11/// An option value in the override file or a catalog argument. There is no
12/// float variant: fractional settings (font scale) are carried as strings
13/// from the catalog's permitted set.
14#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
15#[serde(untagged)]
16pub enum Value {
17    Bool(bool),
18    Int(i64),
19    Str(String),
20    List(Vec<Value>),
21}
22
23// The types `policy::decide` reads, visible to its proofs. `Value` stays
24// opaque to them: the proofs never look inside an option value.
25verus! {
26
27#[verifier::external_type_specification]
28#[verifier::external_body]
29#[allow(dead_code)]
30pub struct ExValue(Value);
31
32/// Option path → (before, after). `None` means unset.
33#[derive(Clone, Debug, Default, PartialEq, Eq)]
34#[derive(Serialize, Deserialize)]
35#[verifier::external_derive]
36pub struct ConfigDiff {
37    pub options: PathMap<(Option<Value>, Option<Value>)>,
38}
39
40/// An in-tree driver is named (`snd_hda_intel`); an out-of-tree one also names
41/// the module's store path. Replaces §8's bare `StorePath` (G6).
42#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
43#[derive(Serialize, Deserialize)]
44#[verifier::external_derive]
45pub struct DriverRef {
46    pub name: String,
47    pub module: Option<StorePath>,
48}
49
50#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
51#[derive(Serialize, Deserialize)]
52#[verifier::external_derive]
53pub enum Resolution {
54    AsCommitted,
55    AsReverted,
56    ManualRepaired,
57}
58
59#[derive(Clone, Debug, PartialEq, Eq)]
60#[derive(Serialize, Deserialize)]
61#[verifier::external_derive]
62pub enum Transition {
63    RestartUnit(UnitId),
64    StopUnit(UnitId),
65    BindDriver {
66        device: UnitId,
67        driver: DriverRef,
68    },
69    /// Cataloged (§9); the executor computes the diff.
70    ApplyChange {
71        change: ChangeId,
72        args: BTreeMap<String, Value>,
73    },
74    SwitchGeneration(GenerationId),
75    Revert {
76        to: GenerationId,
77    },
78    /// To `known_good` (or `accepted_baseline`).
79    Rollback,
80    /// Phase B.
81    LoadModel(ModelId),
82    /// Phase C.
83    RestoreSnapshot(SnapshotId),
84    CollectGarbage {
85        emergency: bool,
86    },
87    SetRuntimeOption {
88        path: String,
89        value: Value,
90    },
91    /// Leave-alone only; does not stop anything.
92    SetOverride(Override),
93    LiftOverride(UnitId),
94    /// The user's "stop X": suppression journaled, then stop.
95    StopAndSuppress {
96        unit: UnitId,
97        until: Until,
98        reason: String,
99    },
100    /// `planarian resolve <txn>`: the only way to clear the recovery interlock (§12c).
101    /// Administrator-only; not in §8's list but §12c calls it a transition.
102    Resolve {
103        txn: TxnId,
104        resolution: Resolution,
105    },
106}
107
108/// Ordered; the autonomy setting compares with `>` (`rank` in proofs).
109#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
110#[derive(Serialize, Deserialize)]
111#[verifier::external_derive]
112pub enum Tier {
113    Observe,
114    Runtime,
115    Tested,
116    AskFirst,
117}
118
119#[derive(Clone, Debug, PartialEq, Eq)]
120#[derive(Serialize, Deserialize)]
121#[verifier::external_derive]
122pub enum Decision {
123    Allow { tier: Tier, approver: ApproverClass },
124    Reject(RejectReason),
125}
126
127/// Who may approve an AskFirst decision. The class is the maximum over every
128/// floor and table row that raised the decision; it is ignored below AskFirst.
129/// `Administrator` requires the approver to hold `Capability::Administrator`
130/// as well as `Capability::Approve`.
131#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
132#[derive(Serialize, Deserialize)]
133#[verifier::external_derive]
134pub enum ApproverClass {
135    AnyApprover,
136    Administrator,
137}
138
139/// `Unauthorized`, `Interrupted` and `NoSpace` are used by §11, §12 and §12c
140/// but were missing from §8 (G1). `Refinement` names the §12 step 3 rejection.
141#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
142#[derive(Serialize, Deserialize)]
143#[verifier::external_derive]
144pub enum RejectReason {
145    Forbidden,
146    CoreTarget,
147    ProbeTamper,
148    NotInCatalog,
149    LockedOption,
150    Stale,
151    Unauthorized,
152    Interrupted,
153    NoSpace,
154    Refinement,
155    ApprovalExpired,
156    /// The transition's precondition does not hold (§12 step 5, C3).
157    Precondition,
158    /// The dry activation failed, timed out or was cut short: what the
159    /// change would start, stop or restart is unknown (§9 step 3, R10, R04).
160    ActivationUnknown,
161    Other(String),
162}
163
164/// What a transition costs the user, as a set (§11, C10). A change can stop
165/// one unit's session and drop another's network: each kind it carries is a
166/// member, and each §11 floor applies when any member triggers it. A single
167/// value folded by `max` loses floors, because the floors are not ordered
168/// (the session floor spares the caller's own session, the connectivity floor
169/// spares a seated caller). Flags, not a collection: the proofs read them
170/// directly. None is the empty set.
171#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
172#[derive(Serialize, Deserialize)]
173#[verifier::external_derive]
174#[serde(from = "DisruptionsRepr")]
175pub struct Disruptions {
176    pub brief: bool,
177    pub session_loss: bool,
178    pub data_interruption: bool,
179    pub connectivity_loss: bool,
180}
181
182/// Derived by the executor from the evaluated generation, never from the
183/// proposal (I14).
184#[derive(Clone, Debug, PartialEq, Eq)]
185#[derive(Serialize, Deserialize)]
186#[verifier::external_derive]
187pub struct DerivedFacts {
188    /// Sorted and unique (`unit_set`).
189    pub affected_units: Vec<UnitId>,
190    pub sensitivity: Sensitivities,
191    pub disruption: Disruptions,
192    pub recovery: Recovery,
193    pub touches_manifest: bool,
194    /// A canonical probe's *definition* changed (protected by I10).
195    pub touches_canonical_probe: bool,
196    /// Which units are desired-present changed; makes probes (in)applicable.
197    /// Not tampering (§9; missing from §8, G2).
198    pub changes_desired: bool,
199    pub touches_core: bool,
200    /// The target generation (revert, rollback, switch) is below the security floor (§12e).
201    #[serde(default)]
202    pub below_security_floor: bool,
203    /// A system-scope effect reaches another uid's online or active session (§11).
204    #[serde(default)]
205    pub other_sessions_affected: bool,
206    /// Logind sessions the transition's disruption lands on, sorted. The
207    /// "own session" floor (§11) holds only when this is exactly the caller's
208    /// session; empty means not known, which never counts as own. Not in §8.
209    #[serde(default)]
210    pub affected_sessions: Vec<String>,
211    /// A RestartUnit that restores a unit stopped outside Planarian and not
212    /// attributed (§11 residue): stopped, not failed, under no override.
213    /// Derived from systemd's state, never from a proposal.
214    #[serde(default)]
215    pub restores_unattributed_stop: bool,
216    /// A RestartUnit whose unit has already failed (observed `Failed`, or not
217    /// running while it is desired running) in the observation the facts are
218    /// derived against; `Unknown` is not failed. Waives the External
219    /// escalation of the RestartUnit row, never the Security one (§11; owner
220    /// decision, Nov 2).
221    #[serde(default)]
222    pub restarts_failed_unit: bool,
223    /// The sensitivity of the units a generation transition would remove or
224    /// stop: present now and absent from the target's manifest, or stopped by
225    /// its activation. A Rollback that takes away a Security- or
226    /// External-sensitive unit (a VPN) asks (§11 Rollback row; owner
227    /// decision, Nov 2). Empty for every other transition.
228    #[serde(default)]
229    pub removes_sensitivity: Sensitivities,
230    /// The units behind `removes_sensitivity`, sorted: what an approval shows
231    /// as removed or stopped (§16). Journaled in this form since 4bb9c99.
232    #[serde(default)]
233    pub removes: Vec<UnitId>,
234    /// `removes[i]`'s own sensitivity, from the decision's lookup, so the
235    /// display and the decision agree. A parallel field, not a change to
236    /// `removes`, so a record journaled before it still decodes (and an older
237    /// build ignores it). Absent or of another length: the display shows
238    /// `removes_sensitivity` for each and says so.
239    #[serde(default)]
240    pub removes_each: Vec<Sensitivities>,
241    /// A RestartUnit of a network-security unit (§12d: a network tool the
242    /// kernel attributes traffic-steering changes to) that has failed: its
243    /// traffic is held, so the restart only ends an outage and runs at once
244    /// whatever its sensitivity (§11; owner decision, Nov 2).
245    #[serde(default)]
246    pub restarts_held_unit: bool,
247    pub caller: Actor,
248    pub config_diff: Option<ConfigDiff>,
249}
250
251impl Tier {
252    /// Observe 0, Runtime 1, Tested 2, AskFirst 3: the derived `Ord`.
253    pub open spec fn rank(self) -> u8 {
254        match self {
255            Tier::Observe => 0,
256            Tier::Runtime => 1,
257            Tier::Tested => 2,
258            Tier::AskFirst => 3,
259        }
260    }
261
262    pub fn rank_of(&self) -> (r: u8)
263        ensures r == self.rank(),
264    {
265        match self {
266            Tier::Observe => 0,
267            Tier::Runtime => 1,
268            Tier::Tested => 2,
269            Tier::AskFirst => 3,
270        }
271    }
272}
273
274/// Per-transition recovery strategy, fixed by the spec, not the proposer (§12).
275#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
276#[verifier::external_derive]
277pub enum RecoveryStrategy {
278    ReobserveOnly,
279    RevertGeneration,
280    UnbindDevice,
281    RestoreSnapshotThenRevert,
282}
283
284#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
285#[verifier::external_derive]
286pub enum Handler {
287    UnitOp,
288    GenerationOp,
289    DeviceOp,
290    SnapshotOp,
291    GcOp,
292    IntentOp,
293}
294
295impl Transition {
296    /// The §12 execution table. Independent of tier.
297    pub open spec fn handler_spec(self) -> Handler {
298        match self {
299            Transition::RestartUnit(_) | Transition::StopUnit(_) | Transition::SetRuntimeOption { .. } => {
300                Handler::UnitOp
301            },
302            Transition::SetOverride(_) | Transition::LiftOverride(_) | Transition::StopAndSuppress { .. }
303            | Transition::Resolve { .. } => Handler::IntentOp,
304            Transition::CollectGarbage { .. } => Handler::GcOp,
305            Transition::ApplyChange { .. } | Transition::SwitchGeneration(_) | Transition::Revert { .. }
306            | Transition::Rollback | Transition::LoadModel(_) => Handler::GenerationOp,
307            Transition::BindDriver { .. } => Handler::DeviceOp,
308            Transition::RestoreSnapshot(_) => Handler::SnapshotOp,
309        }
310    }
311
312    pub open spec fn recovery_spec(self) -> RecoveryStrategy {
313        match self.handler_spec() {
314            Handler::UnitOp | Handler::IntentOp | Handler::GcOp => RecoveryStrategy::ReobserveOnly,
315            Handler::GenerationOp => RecoveryStrategy::RevertGeneration,
316            Handler::DeviceOp => RecoveryStrategy::UnbindDevice,
317            Handler::SnapshotOp => RecoveryStrategy::RestoreSnapshotThenRevert,
318        }
319    }
320
321    /// Generation-changing transactions are serialized (I6).
322    pub open spec fn changes_generation_spec(self) -> bool {
323        self.recovery_spec().reverts_generation()
324    }
325
326    pub fn handler(&self) -> (r: Handler)
327        ensures
328            r == self.handler_spec(),
329    {
330        match self {
331            Transition::RestartUnit(_) | Transition::StopUnit(_) | Transition::SetRuntimeOption { .. } => {
332                Handler::UnitOp
333            },
334            Transition::SetOverride(_) | Transition::LiftOverride(_) | Transition::StopAndSuppress { .. }
335            | Transition::Resolve { .. } => Handler::IntentOp,
336            Transition::CollectGarbage { .. } => Handler::GcOp,
337            Transition::ApplyChange { .. } | Transition::SwitchGeneration(_) | Transition::Revert { .. }
338            | Transition::Rollback | Transition::LoadModel(_) => Handler::GenerationOp,
339            Transition::BindDriver { .. } => Handler::DeviceOp,
340            Transition::RestoreSnapshot(_) => Handler::SnapshotOp,
341        }
342    }
343
344    pub fn recovery(&self) -> (r: RecoveryStrategy)
345        ensures
346            r == self.recovery_spec(),
347    {
348        match self.handler() {
349            Handler::UnitOp | Handler::IntentOp | Handler::GcOp => RecoveryStrategy::ReobserveOnly,
350            Handler::GenerationOp => RecoveryStrategy::RevertGeneration,
351            Handler::DeviceOp => RecoveryStrategy::UnbindDevice,
352            Handler::SnapshotOp => RecoveryStrategy::RestoreSnapshotThenRevert,
353        }
354    }
355
356    pub fn changes_generation(&self) -> (r: bool)
357        ensures
358            r == self.changes_generation_spec(),
359    {
360        match self.recovery() {
361            RecoveryStrategy::RevertGeneration | RecoveryStrategy::RestoreSnapshotThenRevert => true,
362            _ => false,
363        }
364    }
365}
366
367impl RecoveryStrategy {
368    /// Recovery reverts the generation (generation and snapshot operations).
369    pub open spec fn reverts_generation(self) -> bool {
370        match self {
371            RecoveryStrategy::RevertGeneration | RecoveryStrategy::RestoreSnapshotThenRevert => true,
372            _ => false,
373        }
374    }
375}
376
377} // verus!
378
379impl Disruptions {
380    /// The set holding `d` alone (`None` is the empty set).
381    pub fn of(d: Disruption) -> Disruptions {
382        let mut s = Disruptions::default();
383        s.insert(d);
384        s
385    }
386
387    pub fn insert(&mut self, d: Disruption) {
388        match d {
389            Disruption::None => {}
390            Disruption::Brief => self.brief = true,
391            Disruption::SessionLoss => self.session_loss = true,
392            Disruption::DataInterruption => self.data_interruption = true,
393            Disruption::ConnectivityLoss => self.connectivity_loss = true,
394        }
395    }
396
397    pub fn contains(&self, d: Disruption) -> bool {
398        match d {
399            Disruption::None => self.is_none(),
400            Disruption::Brief => self.brief,
401            Disruption::SessionLoss => self.session_loss,
402            Disruption::DataInterruption => self.data_interruption,
403            Disruption::ConnectivityLoss => self.connectivity_loss,
404        }
405    }
406
407    pub fn is_none(&self) -> bool {
408        *self == Disruptions::default()
409    }
410
411    /// Its members, cheapest first (for display and metrics).
412    pub fn members(&self) -> Vec<Disruption> {
413        [Disruption::Brief, Disruption::SessionLoss, Disruption::DataInterruption, Disruption::ConnectivityLoss]
414            .into_iter()
415            .filter(|d| self.contains(*d))
416            .collect()
417    }
418}
419
420impl From<Disruption> for Disruptions {
421    fn from(d: Disruption) -> Disruptions {
422        Disruptions::of(d)
423    }
424}
425
426/// A journal written before C10 holds one `Disruption`; it reads back as the
427/// set holding it.
428#[derive(Deserialize)]
429#[serde(untagged)]
430enum DisruptionsRepr {
431    One(Disruption),
432    Set {
433        #[serde(default)]
434        brief: bool,
435        #[serde(default)]
436        session_loss: bool,
437        #[serde(default)]
438        data_interruption: bool,
439        #[serde(default)]
440        connectivity_loss: bool,
441    },
442}
443
444impl From<DisruptionsRepr> for Disruptions {
445    fn from(r: DisruptionsRepr) -> Disruptions {
446        match r {
447            DisruptionsRepr::One(d) => Disruptions::of(d),
448            DisruptionsRepr::Set { brief, session_loss, data_interruption, connectivity_loss } => {
449                Disruptions { brief, session_loss, data_interruption, connectivity_loss }
450            }
451        }
452    }
453}
454
455/// Sorted, without duplicates: the form of `DerivedFacts::affected_units`.
456pub fn unit_set(units: impl IntoIterator<Item = UnitId>) -> Vec<UnitId> {
457    let set: BTreeSet<UnitId> = units.into_iter().collect();
458    set.into_iter().collect()
459}
460
461impl ConfigDiff {
462    /// Compose `self` then `next`: keep the earliest `before` and the latest `after`;
463    /// drop entries that end where they began.
464    pub fn then(&self, next: &ConfigDiff) -> ConfigDiff {
465        let mut out = self.options.clone();
466        for (path, (before, after)) in &next.options {
467            let first = match out.get(path) {
468                Some((b, _)) => b.clone(),
469                None => before.clone(),
470            };
471            out.insert(path.clone(), (first, after.clone()));
472        }
473        out.retain(|_, (b, a)| b != a);
474        ConfigDiff { options: out }
475    }
476
477    pub fn hash(&self) -> Hash {
478        Hash::of(&serde_json::to_vec(self).expect("diff serializes"))
479    }
480}
481
482/// What evaluating a staged source yields (§9 step 2).
483#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
484pub struct EvaluatedGeneration {
485    pub source: Hash,
486    pub options: BTreeMap<String, Value>,
487    pub units: UnitDefs,
488    /// Units instantiated once per logged-in user (`Scope::User { uid: 0 }` as
489    /// a placeholder); `state` expands them per session (§10).
490    pub user_units: Vec<crate::unit::UnitDef>,
491    /// Filled in by `build` (dry-activate); empty after evaluation alone.
492    pub activation: ActivationPlan,
493    /// Option paths whose highest-priority definition beats Planarian's
494    /// `mkOverride 90` (the owner used `mkForce`): `locked` (§9).
495    pub locked: BTreeSet<String>,
496    /// Content hashes of the evaluated unit files for `planariad` and
497    /// `planarian-recovery`; a difference from the running ones is `touches_core`.
498    pub core_units: BTreeMap<String, Hash>,
499    /// The system's packages, pname → version (§12e).
500    #[serde(default)]
501    pub packages: BTreeMap<String, String>,
502    /// Those whose nixpkgs `meta.knownVulnerabilities` is not empty (§12e).
503    #[serde(default)]
504    pub insecure: BTreeSet<String>,
505    /// Every system service the generation declares, watched or not, with
506    /// the files that define it (NixOS's definition locations): the
507    /// provenance a malicious declaration leaves (§15).
508    #[serde(default)]
509    pub declarations: BTreeMap<String, Vec<String>>,
510    /// The flake inputs the generation was built from, name → (revision,
511    /// store path), when the owner's flake passes them
512    /// (`services.planarian.flakeInputs`): a declaration from an input is
513    /// reported with the input's lock change (§15).
514    #[serde(default)]
515    pub inputs: BTreeMap<String, (String, String)>,
516    /// The shipped tables' sensitivity for every system service NixOS
517    /// defines that the manifest does not declare (§11 sensitivity
518    /// completeness): a unit in neither has no entry, and fails closed.
519    #[serde(default)]
520    pub services: BTreeMap<String, Sensitivities>,
521}
522
523impl EvaluatedGeneration {
524    /// §12e: packages this generation fixes relative to `base`: insecure in
525    /// `base`, still present here, and no longer insecure.
526    pub fn security_fixes(&self, base: &EvaluatedGeneration) -> Vec<String> {
527        base.insecure
528            .iter()
529            .filter(|p| self.packages.contains_key(*p) && !self.insecure.contains(*p))
530            .cloned()
531            .collect()
532    }
533}
534
535#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
536pub struct ActivationPlan {
537    pub start: BTreeSet<UnitId>,
538    pub stop: BTreeSet<UnitId>,
539    pub restart: BTreeSet<UnitId>,
540    pub reload: BTreeSet<UnitId>,
541    /// Kernel, initrd, bootloader or filesystem options changed: can become
542    /// `live_tested` but not `known_good` until a reboot confirms it (§10).
543    pub needs_reboot_to_confirm: bool,
544}
545
546impl ActivationPlan {
547    /// Takes effect only at boot (kernel, initrd, drivers): nothing to start,
548    /// stop, restart or reload now (§12f boot-only path).
549    pub fn boot_only(&self) -> bool {
550        self.needs_reboot_to_confirm
551            && self.start.is_empty()
552            && self.stop.is_empty()
553            && self.restart.is_empty()
554            && self.reload.is_empty()
555    }
556}
557
558pub trait TransitionSpec {
559    fn precondition(&self, s: &SystemState, defs: &UnitDefs) -> bool;
560    fn predict(&self, s: &SystemState, defs: &UnitDefs) -> Expectation;
561    fn derive(&self, s: &SystemState, defs: &UnitDefs, generation: &EvaluatedGeneration, caller: Actor)
562    -> DerivedFacts;
563}
564
565#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
566pub struct Expectation {
567    pub units: BTreeMap<UnitId, UnitStatus>,
568    pub generation: Option<GenerationId>,
569    pub may_change: BTreeSet<UnitId>,
570    pub others_unchanged: bool,
571    pub settle: Settle,
572}
573
574#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
575pub enum Settle {
576    Duration(Millis),
577    Ready { unit: UnitId, timeout: Millis },
578}
579
580#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
581pub struct Proposal {
582    pub observed: Hash,
583    pub transition: Transition,
584    /// MUST refine `spec.predict()`; checked in §12 step 3.
585    pub expected: Expectation,
586    pub reason: String,
587    pub source: Source,
588}
589
590#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
591pub enum Source {
592    Diagnoser {
593        incident: IncidentId,
594    },
595    Model(ModelId),
596    User,
597    /// The daemon's own housekeeping: collection under the §12b bounds.
598    Maintenance,
599    /// Confidence in per-mille, as `Confidence`.
600    Intent {
601        phrase: String,
602        confidence: crate::unit::Confidence,
603    },
604}
605
606#[cfg(test)]
607mod tests {
608
609    /// C10: a journal written before the set reads its single disruption back
610    /// as the set holding it; the set round-trips; a missing member is false.
611    #[test]
612    fn disruptions_read_the_old_single_value() {
613        let old: Disruptions = serde_json::from_str("\"SessionLoss\"").unwrap();
614        assert_eq!(old, Disruptions::of(Disruption::SessionLoss));
615        let none: Disruptions = serde_json::from_str("\"None\"").unwrap();
616        assert!(none.is_none());
617        let mut both = Disruptions::of(Disruption::ConnectivityLoss);
618        both.insert(Disruption::DataInterruption);
619        let back: Disruptions = serde_json::from_str(&serde_json::to_string(&both).unwrap()).unwrap();
620        assert_eq!(back, both);
621        assert_eq!(both.members(), vec![Disruption::DataInterruption, Disruption::ConnectivityLoss]);
622        let partial: Disruptions = serde_json::from_str("{\"brief\":true}").unwrap();
623        assert_eq!(partial, Disruptions::of(Disruption::Brief));
624    }
625    use super::*;
626
627    #[test]
628    fn strategy_table() {
629        let u = UnitId::service("x");
630        assert_eq!(Transition::RestartUnit(u.clone()).recovery(), RecoveryStrategy::ReobserveOnly);
631        assert_eq!(
632            Transition::StopAndSuppress { unit: u.clone(), until: Until::Explicit, reason: String::new() }.recovery(),
633            RecoveryStrategy::ReobserveOnly
634        );
635        // Rollback is a generation operation even though its tier may be Runtime.
636        assert_eq!(Transition::Rollback.recovery(), RecoveryStrategy::RevertGeneration);
637        assert!(Transition::Rollback.changes_generation());
638        let bind = Transition::BindDriver {
639            device: UnitId::device("d"),
640            driver: DriverRef { name: "snd_hda_intel".into(), module: None },
641        };
642        assert_eq!(bind.recovery(), RecoveryStrategy::UnbindDevice);
643        assert!(!bind.changes_generation());
644        assert!(!Transition::CollectGarbage { emergency: true }.changes_generation());
645    }
646
647    #[test]
648    fn diff_composition() {
649        let d = |p: &str, b: Option<i64>, a: Option<i64>| ConfigDiff {
650            options: [(p.to_owned(), (b.map(Value::Int), a.map(Value::Int)))].into(),
651        };
652        let both = d("x", Some(1), Some(2)).then(&d("x", Some(2), Some(3)));
653        assert_eq!(both, d("x", Some(1), Some(3)));
654        let undone = d("x", Some(1), Some(2)).then(&d("x", Some(2), Some(1)));
655        assert!(undone.options.is_empty());
656    }
657}