Skip to main content

planarian_gym/
sim.rs

1//! The fake harness: a fake machine, the real daemon core, and a watchdog
2//! process, driven tick by tick by a catalog entry's steps.
3
4use planarian_changes::{CatalogConfig, ListenAddress};
5use planarian_daemon::{Core, CoreConfig, Peer};
6use planarian_exec::Config as ExecConfig;
7use planarian_exec::host::ExecHost;
8use planarian_fake::{Fake, Health, MemStore, fixtures, gym_world};
9use planarian_ipc::{Request, Response};
10use planarian_policy::{Settings, Tables};
11use planarian_schema::*;
12use planarian_txn::journal::{Journal, JournalError};
13use planarian_txn::kernel::{CrashPoint, Kernel};
14use planarian_txn::ledger::{Entry, Ledger, Record};
15use planarian_txn::machine::tag;
16use planarian_txn::store::Store as _;
17use std::collections::{BTreeMap, BTreeSet};
18use toml::Value as T;
19
20pub const TICK: Millis = Millis::secs(5);
21pub const DAEMON_PID: u32 = 100;
22pub const WATCHDOG_PID: u32 = 200;
23
24#[derive(Debug)]
25pub struct Unsupported(pub String);
26
27pub fn config() -> CoreConfig {
28    CoreConfig {
29        exec: ExecConfig {
30            settings: Settings::default(),
31            tables: Tables {
32                catalog: [
33                    "service-enable",
34                    "font-scale",
35                    "listen-address",
36                    "repair:reenable-predicted",
37                    "repair:reapply",
38                    "gym:security-fix",
39                    "gym:kernel-param",
40                    "gym:touch-core",
41                    "gym:newlib",
42                    "gym:drop-probe",
43                    "gym:firewall-step",
44                ]
45                .map(|s| ChangeId(s.into()))
46                .into(),
47                runtime_allowlist: ["font.scale", "wifi.network"].map(String::from).into(),
48                option_sensitivity: vec![
49                    ("services.openssh".into(), Sensitivities::of(&[Sensitivity::Security])),
50                    ("networking.firewall".into(), Sensitivities::of(&[Sensitivity::Security])),
51                ],
52                model_catalog: Default::default(),
53            },
54            catalog: CatalogConfig {
55                services: [
56                    ("sshd".into(), "services.openssh.enable".into()),
57                    // Declared, off by default (catalog family malicious-declaration).
58                    ("gym-bad-payload".into(), "services.gym-bad-payload.enable".into()),
59                ]
60                .into(),
61                font_scale_option: "planarian.reference.fontScale".into(),
62                font_scales: ["0.75", "1", "1.25", "1.5", "2"].map(String::from).into(),
63                listen_addresses: [(
64                    "gym-web".into(),
65                    ListenAddress {
66                        option: "services.gym-web.listenAddress".into(),
67                        permitted: vec!["127.0.0.1".into(), "192.0.2.10".into()],
68                    },
69                )]
70                .into(),
71                gym_choices: [
72                    (
73                        "gym:security-fix".into(),
74                        planarian_changes::GymChoice {
75                            option: "gym.vulnlib".into(),
76                            arg: "version".into(),
77                            permitted: vec!["1.1".into(), "1.2".into()],
78                        },
79                    ),
80                    (
81                        "gym:newlib".into(),
82                        planarian_changes::GymChoice {
83                            option: "gym.newlib".into(),
84                            arg: "version".into(),
85                            permitted: vec!["1.0".into(), "2.0".into()],
86                        },
87                    ),
88                    (
89                        "gym:touch-core".into(),
90                        planarian_changes::GymChoice {
91                            option: "gym.touchCore".into(),
92                            arg: "value".into(),
93                            permitted: vec!["on".into()],
94                        },
95                    ),
96                    (
97                        "gym:drop-probe".into(),
98                        planarian_changes::GymChoice {
99                            option: "gym.dropProbe".into(),
100                            arg: "probe".into(),
101                            permitted: vec!["sshd-tcp".into()],
102                        },
103                    ),
104                    (
105                        "gym:firewall-step".into(),
106                        planarian_changes::GymChoice {
107                            option: "networking.firewall.gymStep".into(),
108                            arg: "step".into(),
109                            permitted: vec!["1".into()],
110                        },
111                    ),
112                    (
113                        "gym:kernel-param".into(),
114                        planarian_changes::GymChoice {
115                            option: "gym.kernelParam".into(),
116                            arg: "param".into(),
117                            permitted: vec!["virtio_gpu.modeset=0".into()],
118                        },
119                    ),
120                ]
121                .into(),
122                ..Default::default()
123            },
124            owner: PinnedSource { root: "/etc/nixos".into(), kind: SourceKind::Git },
125            canonical: fixtures::canonical(),
126        },
127        runtime_options: vec![],
128        fresh_boot: true,
129        startup_grace: planarian_daemon::STARTUP_GRACE,
130        posture: planarian_schema::Posture::Standard,
131        posture_decl: Default::default(),
132        agent_limits: Default::default(),
133        // As planariad: recoveries that may activate run after ready (#9).
134        defer_recovery: true,
135    }
136}
137
138pub struct Sim {
139    pub fake: Fake,
140    pub core: Option<Core<Fake, MemStore>>,
141    /// The posture `set_setting posture` chose; kept across daemon restarts.
142    pub posture: planarian_schema::Posture,
143    /// Reboots so far: the boot id the daemon and watchdog see.
144    pub boots: u64,
145    fresh_boot: bool,
146    daemon_pid: u32,
147    /// The daemon is killed and must not be restarted (catalog `hold_down`).
148    held: bool,
149    crash: Option<CrashPoint>,
150    pub start: Timestamp,
151    pub responses: Vec<Response>,
152    pub notices: Vec<String>,
153    /// Generation ids for the catalog's symbolic names.
154    pub base: GenerationId,
155    pub candidate: Option<GenerationId>,
156    /// Executor log records (I7) from every daemon instance.
157    pub log: Vec<planarian_daemon::LogRecord>,
158    /// Every status each unit showed, by tick.
159    pub seen_status: BTreeMap<UnitId, std::collections::BTreeSet<String>>,
160    /// Every incident ever opened: (target, kind, first seen, last state, regressions, budget).
161    pub seen_incidents: BTreeMap<IncidentId, Incident>,
162    pub first_incident_at: BTreeMap<UnitId, Timestamp>,
163    /// Statuses just before the first step (for `others_unchanged`).
164    pub pre_status: BTreeMap<UnitId, String>,
165    /// Units the entry's own steps act on (exempt from `others_unchanged`).
166    pub touched: std::collections::BTreeSet<UnitId>,
167    /// Journal entries saved before a compaction discards them, and whether one happened.
168    history: Vec<Entry>,
169    compacted: bool,
170    torn_seen: bool,
171    /// Approvals issued and held back (`approve_pending` with `hold_token`).
172    held_tokens: Vec<Hash>,
173    timeline: BTreeMap<UnitId, Vec<(Timestamp, String)>>,
174    /// Each `foreground` step: when, which session, on or off.
175    foreground_steps: Vec<(Timestamp, String, bool)>,
176    start_probe_defs: String,
177    /// Stages and builds when the entry's steps began.
178    start_work: (usize, usize),
179    start_roots: std::collections::BTreeSet<String>,
180    /// planariad's side of the failure posture (§12d); see `PostureSeen`.
181    pub posture_seen: crate::evidence::PostureSeen,
182}
183
184/// `root`, `user:<uid>` or `user:<uid>@<session>`; user:1001 is the gym's administrator.
185pub fn peer(actor: &str) -> Peer {
186    let (who, session) = match actor.split_once('@') {
187        Some((w, s)) => (w, Some(s)),
188        None => (actor, None),
189    };
190    if let Some(name) = who.strip_prefix("agent:") {
191        // A dynamic uid, as systemd would give it.
192        // Its instance is filled in when it sends (`request_peer`): the
193        // running unit's, as its cgroup would show it.
194        return Peer::agent(61184, name, None);
195    }
196    let p = match who {
197        "root" => Peer::new(0, true),
198        // I17: root with no administrator group behind it (an emptied
199        // planarian-admin); root is an administrator all the same.
200        "root:nogroup" => Peer::new(0, false),
201        "user:1001" => Peer::new(1001, true),
202        a => Peer::new(a.strip_prefix("user:").and_then(|u| u.parse().ok()).unwrap_or(1000), false),
203    };
204    match session {
205        Some(s) => p.in_session(s),
206        None => p,
207    }
208}
209
210/// The authorization the daemon would compute for `actor` on the gym's seat:
211/// a local, active session (`gym` unless the actor names one).
212/// The gym's administrators: root and uid 1001 (in `planarian-admin`).
213fn gym_admins() -> std::sync::Arc<dyn Fn(u32) -> bool + Send + Sync> {
214    std::sync::Arc::new(|uid| uid == 0 || uid == 1001)
215}
216
217fn gym_authz(actor: &str) -> planarian_schema::Authorization {
218    let pe = peer(actor);
219    let session = pe.session.clone().unwrap_or_else(|| "gym".into());
220    let a = Actor::User { uid: pe.uid, session: Some(session), seat: Some("seat0".into()), active: true };
221    planarian_policy::user_authorization(a, pe.admin)
222}
223
224/// "90s", "5m", "24h1m"; "stale_after" means a probe's staleness window (30 s
225/// for the fixtures), optionally "+<duration>".
226pub fn dur(v: &T) -> Option<Millis> {
227    let s = v.as_str()?;
228    if let Some(rest) = s.strip_prefix("stale_after") {
229        let extra = match rest.strip_prefix('+') {
230            Some(r) => dur(&T::String(r.into()))?,
231            None => Millis(0),
232        };
233        return Some(Millis(35_000 + extra.0));
234    }
235    let mut total = 0u64;
236    let mut rest = s;
237    while !rest.is_empty() {
238        let i = rest.find(|c: char| !c.is_ascii_digit())?;
239        let n: u64 = rest[..i].parse().ok()?;
240        let unit_end = rest[i..].find(|c: char| c.is_ascii_digit()).map(|j| i + j).unwrap_or(rest.len());
241        total += match &rest[i..unit_end] {
242            "ms" => n,
243            "s" => n * 1000,
244            "m" => n * 60_000,
245            "h" => n * 3_600_000,
246            _ => return None,
247        };
248        rest = &rest[unit_end..];
249    }
250    Some(Millis(total))
251}
252
253pub fn unit(s: &str) -> UnitId {
254    s.parse().unwrap_or_else(|_| UnitId::service(s))
255}
256
257pub fn value(v: &T) -> Value {
258    match v {
259        T::Boolean(b) => Value::Bool(*b),
260        T::Integer(i) => Value::Int(*i),
261        T::String(s) => Value::Str(s.clone()),
262        other => Value::Str(other.to_string()),
263    }
264}
265
266impl Sim {
267    pub fn new() -> Sim {
268        let store = MemStore::new(16 << 20);
269        let fake = gym_world(store.clone());
270        fake.w().alive.extend([DAEMON_PID, WATCHDOG_PID]);
271        let mut j = Journal::open(store).expect("journal");
272        let now = fake.w().now;
273        j.append(now, Record::KnownGood(GenerationId(1))).expect("enroll");
274        let mut core = Core::start(j, fake.clone(), OwnerId::Daemon { pid: DAEMON_PID, boot_id: Hash::ZERO }, config());
275        core.admin_of = gym_admins();
276        Sim {
277            fake,
278            core: Some(core),
279            posture: planarian_schema::Posture::Standard,
280            boots: 0,
281            fresh_boot: false,
282            daemon_pid: DAEMON_PID,
283            held: false,
284            crash: None,
285            start: now,
286            responses: vec![],
287            notices: vec![],
288            base: GenerationId(1),
289            candidate: None,
290            log: vec![],
291            seen_status: BTreeMap::new(),
292            seen_incidents: BTreeMap::new(),
293            first_incident_at: BTreeMap::new(),
294            pre_status: BTreeMap::new(),
295            touched: Default::default(),
296            history: vec![],
297            compacted: false,
298            torn_seen: false,
299            held_tokens: vec![],
300            timeline: BTreeMap::new(),
301            foreground_steps: vec![],
302            start_probe_defs: String::new(),
303            start_work: (0, 0),
304            start_roots: Default::default(),
305            posture_seen: Default::default(),
306        }
307    }
308
309    /// The run proper starts now: what the machine looks like before the first step.
310    pub fn begin(&mut self) {
311        self.start = self.now();
312        self.pre_status = self.statuses();
313        self.start_probe_defs = self.probe_defs();
314        let w = self.fake.w();
315        self.start_work = (w.stages, w.builds);
316        drop(w);
317        self.start_roots = self.build_roots();
318    }
319
320    fn build_roots(&self) -> std::collections::BTreeSet<String> {
321        self.fake.w().gc_roots.iter().filter(|r| r.contains("/builds/")).cloned().collect()
322    }
323
324    /// The probe definitions the daemon evaluates, per unit.
325    fn probe_defs(&mut self) -> String {
326        let defs = self.observe().1;
327        let probes: BTreeMap<String, Vec<ProbeDef>> =
328            defs.iter().map(|(id, d)| (id.to_string(), d.probes.clone())).collect();
329        serde_json::to_string(&probes).expect("serializes")
330    }
331
332    /// Save the journal's current segment before something discards it.
333    fn save_segment(&mut self) {
334        if let Ok(mut j) = Journal::open(self.fake.store.clone())
335            && let Ok(seg) = j.segment()
336        {
337            for e in seg {
338                if self.history.last().is_none_or(|h| h.seq < e.seq) {
339                    self.history.push(e);
340                }
341            }
342        }
343    }
344
345    pub fn now(&self) -> Timestamp {
346        self.fake.w().now
347    }
348
349    pub fn ledger(&self) -> Ledger {
350        Journal::open(self.fake.store.clone()).map(|j| j.ledger().clone()).unwrap_or_default()
351    }
352
353    fn harvest(&mut self) {
354        let now = self.now();
355        if let Some(c) = self.core.as_mut() {
356            self.notices.extend(c.notices.drain(..));
357            self.log.append(&mut c.log);
358            for i in c.incidents.incidents.values() {
359                self.first_incident_at.entry(i.target.clone()).or_insert(now);
360                self.seen_incidents.insert(i.id, i.clone());
361            }
362        }
363    }
364
365    /// Everything the judge reads (check.rs).
366    pub fn evidence(&mut self) -> crate::evidence::Evidence {
367        let finals = self.statuses();
368        self.save_segment();
369        let entries: Vec<Entry> = self.history.iter().filter(|e| e.at >= self.start).cloned().collect();
370        let full_replay = self.compacted.then(|| {
371            let mut l = Ledger::default();
372            for e in &self.history {
373                let _ = l.apply(e.clone());
374            }
375            l
376        });
377        let probe_defs = (self.start_probe_defs.clone(), self.probe_defs());
378        let gc_roots = (self.start_roots.clone(), self.build_roots());
379        // Which generation each transaction reverts to, to name activations.
380        let revert_to: BTreeMap<TxnId, GenerationId> = self
381            .history
382            .iter()
383            .filter_map(|e| match &e.record {
384                Record::Prepared(t) => Some((t.id, t.revert_to)),
385                _ => None,
386            })
387            .collect();
388        let journal_replays = Journal::open(self.fake.store.clone()).is_ok();
389        let w = self.fake.w();
390        let mut restarts: BTreeMap<UnitId, i64> = BTreeMap::new();
391        for c in w.calls.iter().filter(|c| !c.fenced) {
392            if let Some(u) = c.call.strip_prefix("restart(").and_then(|r| r.strip_suffix(')')) {
393                *restarts.entry(unit(u)).or_default() += 1;
394            }
395        }
396        let name = |call: &str, txn: TxnId| -> String {
397            match call.strip_prefix("activate_live(").and_then(|g| g.strip_suffix(')')).and_then(|g| g.parse().ok()) {
398                Some(g) if revert_to.get(&txn) == Some(&GenerationId(g)) => "activate_live(revert_to)".into(),
399                Some(_) => "activate_live(candidate)".into(),
400                None => call.to_string(),
401            }
402        };
403        let jobs: std::collections::BTreeSet<OpId> = w.job_spans.values().map(|(j, _, _)| j.op).collect();
404        let effects: Vec<crate::evidence::Effect> = w
405            .calls
406            .iter()
407            .filter(|c| !c.fenced && !jobs.contains(&c.op))
408            .map(|c| crate::evidence::Effect {
409                call: name(&c.call, c.op.txn),
410                txn: c.op.txn,
411                epoch: c.op.epoch,
412                seq: c.op.seq,
413                start: c.at,
414                end: Some(c.at),
415            })
416            .chain(w.job_spans.values().map(|(j, start, end)| crate::evidence::Effect {
417                call: name(&format!("activate_live({})", j.activates.0), j.op.txn),
418                txn: j.op.txn,
419                epoch: j.op.epoch,
420                seq: j.op.seq,
421                start: *start,
422                end: *end,
423            }))
424            .collect();
425        let drivers = w.devices.iter().filter_map(|(d, r)| r.as_ref().map(|r| (d.clone(), r.name.clone()))).collect();
426        let options = w.gens.get(&w.current).map(|g| g.eval.options.clone()).unwrap_or_default();
427        let (current, boot_default, boot_entries, collected, overrides_empty) = (
428            w.current,
429            w.boot_default,
430            w.boot_entries.iter().copied().collect(),
431            w.collected.to_vec(),
432            w.overrides.assignments.is_empty(),
433        );
434        let override_hash = Some(planarian_fake::World::override_hash_of(&w.overrides));
435        let work = Some((w.stages - self.start_work.0, w.builds - self.start_work.1));
436        let third_party_override = w.third_party_override;
437        // §12 (R01): boot selection, roots, overrides and the journal agree.
438        let ledger = self.ledger();
439        let mut disagree = vec![];
440        if w.boot_default != w.current {
441            disagree.push(format!("boot default {} is not the running generation {}", w.boot_default.0, w.current.0));
442        }
443        if let Some(g) = w.one_shot {
444            disagree.push(format!("a one-shot entry for generation {} is still armed", g.0));
445        }
446        if let (Some(h), Some(rec)) = (override_hash, w.gen_overrides.get(&w.current))
447            && h != *rec
448        {
449            disagree.push(format!("the override file is not the one generation {} was committed with", w.current.0));
450        }
451        let live_roots: BTreeSet<&String> = ledger.live.values().filter_map(|t| t.txn.gc_root.as_ref()).collect();
452        for r in w.gc_roots.iter().filter(|r| r.contains("/builds/") && !live_roots.contains(r)) {
453            let entry =
454                w.boot_entries.iter().any(|g| w.gens.get(g).is_some_and(|x| r.ends_with(&x.eval.source.to_hex())));
455            if !entry {
456                disagree.push(format!("GC root {r} belongs to no live transaction or boot entry"));
457            }
458        }
459        for t in ledger.live.values() {
460            disagree.push(format!("transaction {} is still {}", t.txn.id.0, tag(&t.txn.state)));
461        }
462        drop(w);
463        crate::evidence::Evidence {
464            current,
465            boot_default,
466            base: self.base,
467            candidate: self.candidate,
468            boot_entries,
469            collected,
470            work,
471            overrides_empty,
472            override_hash,
473            third_party_override,
474            agreement: Some(disagree),
475            ledger,
476            journal_replays,
477            log: self.log.clone(),
478            pre_status: self.pre_status.clone(),
479            finals,
480            touched: self.touched.clone(),
481            seen_status: self.seen_status.clone(),
482            seen_incidents: self.seen_incidents.clone(),
483            open_incidents: self
484                .core
485                .as_ref()
486                .map(|c| {
487                    c.incidents
488                        .incidents
489                        .values()
490                        .filter(|i| i.state != IncidentState::Closed)
491                        .map(|i| (i.target.clone(), i.clone()))
492                        .collect()
493                })
494                .unwrap_or_default(),
495            first_incident_at: self.first_incident_at.clone(),
496            start: self.start,
497            restarts,
498            notices: self.notices.clone(),
499            responses: self.responses.clone(),
500            entries: Some(entries),
501            full_replay,
502            effects: Some(effects),
503            status_timeline: Some(self.timeline.clone()),
504            foreground: Some(self.foreground_steps.clone()),
505            drivers: Some(drivers),
506            options: Some(options),
507            probe_defs: Some(probe_defs),
508            gc_roots: Some(gc_roots),
509            posture: crate::evidence::PostureSeen {
510                chain_broken: matches!(Journal::open(self.fake.store.clone()), Err(JournalError::Integrity(_))),
511                ..self.posture_seen.clone()
512            },
513        }
514    }
515
516    /// The current status of every unit, by name.
517    pub fn statuses(&mut self) -> BTreeMap<UnitId, String> {
518        let (s, defs) = self.observe();
519        defs.keys()
520            .map(|id| (id.clone(), format!("{:?}", s.status(id, &defs)).split('(').next().unwrap_or("").to_string()))
521            .collect()
522    }
523
524    fn observe(&mut self) -> (SystemState, UnitDefs) {
525        match self.core.as_mut() {
526            Some(c) => c.observe(),
527            None => {
528                // The daemon is held down: observe the way it would.
529                let g = self.fake.w().current;
530                let gens = &self.fake;
531                use planarian_schema::GenerationBackend;
532                let m = gens
533                    .path_of(g)
534                    .and_then(|p| gens.evaluate_built(&p))
535                    .map(|e| planarian_exec::derive::manifest_of(&e))
536                    .unwrap_or_default();
537                let mut a = planarian_state::Assembler::new(m, fixtures::canonical());
538                let l = self.ledger();
539                let ctx = planarian_state::Context {
540                    known_good: l.known_good.unwrap_or(g),
541                    live_tested: l.live_tested,
542                    accepted_baseline: l.accepted_baseline,
543                    security_floor: l.security_floor,
544                    overrides: l.overrides.clone(),
545                    core_hash: Hash::ZERO,
546                };
547                let s = a.observe(&self.fake, &ctx);
548                let d = a.defs();
549                (s, d)
550            }
551        }
552    }
553
554    /// Record what the daemon saw at the start of the last tick.
555    fn record_status(&mut self) {
556        if let Some((s, defs)) = self.core.as_ref().and_then(|c| c.last_observation().cloned()) {
557            for id in defs.keys() {
558                let st = s.status(id, &defs);
559                let name = format!("{st:?}");
560                let name = name.split('(').next().unwrap_or("").to_string();
561                let line = self.timeline.entry(id.clone()).or_default();
562                if line.last().is_none_or(|(_, n)| *n != name) {
563                    line.push((s.observed_at, name.clone()));
564                }
565                self.seen_status.entry(id.clone()).or_default().insert(name);
566            }
567        }
568    }
569
570    /// The watchdog's 10 s pass.
571    fn watchdog(&mut self) {
572        if !self.fake.w().alive.contains(&WATCHDOG_PID) {
573            return;
574        }
575        let Ok(j) = Journal::open(self.fake.store.clone()) else { return };
576        let mut k = Kernel::new(
577            j,
578            ExecHost::new(self.fake.clone(), fixtures::canonical()),
579            OwnerId::Watchdog { pid: WATCHDOG_PID, boot_id: self.boot_id() },
580        );
581        let live: Vec<Transaction> = k.journal.ledger().live.values().map(|l| l.txn.clone()).collect();
582        for t in live {
583            if k.watchdog_should_take_over(&t) {
584                let r = k.recover(t.id, t.lease.epoch);
585                self.notices.push(format!("watchdog: transaction {} found in {} → {r:?}", t.id.0, tag(&t.state)));
586            }
587        }
588    }
589
590    pub fn tick(&mut self) {
591        self.fake.advance(TICK);
592        if let Some(c) = self.core.as_mut() {
593            c.tick();
594            if let Some(why) = c.integrity.take() {
595                self.harvest();
596                self.integrity(why);
597            }
598        }
599        self.record_status();
600        if ((self.now().0 - self.start.0) / TICK.0).is_multiple_of(2) {
601            self.watchdog();
602        }
603        self.harvest();
604    }
605
606    fn boot_id(&self) -> Hash {
607        if self.boots == 0 { Hash::ZERO } else { Hash::of(format!("boot {}", self.boots).as_bytes()) }
608    }
609
610    fn kill_daemon(&mut self) {
611        self.fake.w().alive.remove(&self.daemon_pid);
612        self.core = None;
613    }
614
615    /// planariad stopped with status 78: posture.nix routes it to Integrity.
616    fn integrity(&mut self, why: String) {
617        self.kill_daemon();
618        self.held = true;
619        self.posture_seen.classes.insert("integrity".into());
620        self.posture_seen.finding = Some(why.clone());
621        self.notices.push(format!("INTEGRITY: {why}"));
622    }
623
624    fn start_daemon(&mut self) {
625        self.daemon_pid += 1;
626        let pid = self.daemon_pid;
627        let j = match Journal::open(self.fake.store.clone()) {
628            Ok(j) => j,
629            Err(JournalError::Integrity(why)) => {
630                return self.integrity(format!("journal: integrity: {why}"));
631            }
632            Err(e) => panic!("journal: {e:?}"),
633        };
634        self.fake.w().alive.insert(pid);
635        let mut cfg = config();
636        cfg.fresh_boot = std::mem::take(&mut self.fresh_boot);
637        cfg.posture = self.posture;
638        let boot_id = self.boot_id();
639        let mut core = Core::start(j, self.fake.clone(), OwnerId::Daemon { pid, boot_id }, cfg);
640        core.admin_of = gym_admins();
641        // Ready is reached here in the fake (no unit ordering); then, as
642        // planariad does, the recoveries left until after ready.
643        core.finish_recovery();
644        // The executor log of the earlier runs, as the daemon reads it back.
645        core.remember(self.log.clone());
646        self.core = Some(core);
647        self.harvest();
648    }
649
650    /// Send a request as `actor`; a crash point set by a later `kill_process`
651    /// fires inside it.
652    fn request(&mut self, actor: &str, req: Request) -> Result<(), Unsupported> {
653        self.request_peer(peer(actor), req)
654    }
655
656    fn request_peer(&mut self, mut p: Peer, req: Request) -> Result<(), Unsupported> {
657        if let (Some(name), None) = (&p.agent, p.instance) {
658            p.instance = self.fake.w().agent_instances.get(name).copied();
659        }
660        let Some(core) = self.core.as_mut() else { return Err(Unsupported("request while the daemon is down".into())) };
661        core.ex.kernel.crash_at = self.crash.clone();
662        let r = core.handle(p, req);
663        let crashed = matches!(&r, Response::Error { message } if message.contains("Crashed"));
664        if let Some(c) = self.core.as_mut() {
665            c.ex.kernel.crash_at = None;
666        }
667        self.responses.push(r);
668        self.harvest();
669        // An interrupted journal write is a crash mid-write: the process is gone.
670        if crashed || (self.fake.store.torn() && !self.torn_seen) {
671            self.torn_seen = self.fake.store.torn();
672            self.kill_daemon();
673            self.crash = None;
674        }
675        self.track_candidate();
676        Ok(())
677    }
678
679    fn track_candidate(&mut self) {
680        let l = self.ledger();
681        // The generation the entry's change builds: a rollback's, revert's or
682        // switch's target is an existing generation, not a candidate.
683        if let Some(c) = l
684            .live
685            .values()
686            .filter(|t| {
687                !matches!(
688                    t.txn.transition,
689                    Transition::Rollback | Transition::Revert { .. } | Transition::SwitchGeneration { .. }
690                )
691            })
692            .filter_map(|t| t.txn.candidate.as_ref().map(|(g, _)| *g))
693            .next_back()
694        {
695            self.candidate = Some(c);
696        }
697        let w = self.fake.w();
698        if self.candidate.is_none() && w.gens.len() > 1 {
699            drop(w);
700            self.candidate = self.fake.w().gens.keys().copied().max().filter(|g| *g != self.base);
701        }
702    }
703
704    /// Run until `pred` holds or `limit` passes.
705    fn until(&mut self, limit: Millis, mut pred: impl FnMut(&mut Sim) -> bool) {
706        let end = self.now().plus(limit);
707        while self.now() < end && !pred(self) {
708            self.tick();
709        }
710    }
711
712    fn journal_reached(&self, state: &str) -> bool {
713        let l = self.ledger();
714        l.live.values().any(|t| t.path.iter().any(|p| p == state))
715            || l.terminal.values().any(|t| t.path.iter().any(|p| p == state))
716    }
717
718    /// Pre-scan the steps. Transactions run synchronously here, so a step
719    /// timed to a journal state (`when = { journal = X }`) is armed before the
720    /// transaction it refers to: a planariad kill becomes a crash point on the
721    /// last transaction before it; the other faults become hooks in the fake
722    /// machine. Returns the indices of steps consumed this way, and the index
723    /// of the step the crash point belongs to.
724    pub fn arm(&mut self, steps: &[&T]) -> (std::collections::BTreeSet<usize>, Option<(usize, CrashPoint)>) {
725        let mut consumed = std::collections::BTreeSet::new();
726        let mut crash = None;
727        let is_txn = |s: &T| {
728            s.get("transition").is_some()
729                || matches!(s.get("raw").and_then(T::as_str), Some("ask" | "cli" | "approve_pending"))
730        };
731        for (i, s) in steps.iter().enumerate() {
732            for (key, point) in [("op_called", CrashPoint::OpCalled as fn(_) -> _), ("op_begun", CrashPoint::OpBegun)] {
733                if let Some(call) = s.get("when").and_then(|w| w.get(key)).and_then(T::as_str) {
734                    let call: &'static str = Box::leak(call.to_string().into_boxed_str());
735                    if let Some(j) = (0..i).rev().find(|j| is_txn(steps[*j])) {
736                        crash = Some((j, point(call)));
737                    }
738                }
739            }
740            if s.get("when").is_some_and(|w| w.get("op_called").is_some() || w.get("op_begun").is_some()) {
741                continue;
742            }
743            let Some(state) = s.get("when").and_then(|w| w.get("journal")).and_then(T::as_str) else { continue };
744            let get = |k: &str| s.get(k).and_then(T::as_str).unwrap_or("");
745            match get("raw") {
746                "kill_process" if get("process") == "planariad" => {
747                    let state: &'static str = Box::leak(state.to_string().into_boxed_str());
748                    // With a slow activation worker set up, "during Activating"
749                    // means while that worker runs: once the call has started it.
750                    let slow_worker = steps.iter().any(|x| {
751                        x.get("raw").and_then(T::as_str) == Some("slow_backend")
752                            && x.get("call").and_then(T::as_str) == Some("activate_live")
753                    });
754                    let point = if state == "OpId" || (state == "Activating" && slow_worker) {
755                        CrashPoint::OpCalled("activate_live")
756                    } else {
757                        CrashPoint::State(state)
758                    };
759                    if let Some(j) = (0..i).rev().find(|j| is_txn(steps[*j])) {
760                        crash = Some((j, point));
761                    }
762                }
763                // The write of the record entering this state is torn.
764                "interrupt_write" if get("target") == "record" => {
765                    self.fake.store.tear_write_containing(&format!("\"to\":\"{state}\""));
766                    consumed.insert(i);
767                }
768                "edit_owner_source" if s.get("tracked").and_then(T::as_bool) != Some(false) => {
769                    self.fake.w().edit_on_stage = true;
770                    consumed.insert(i);
771                }
772                "fill_fs" if get("fs") == "device:fs-nix" => {
773                    let mut w = self.fake.w();
774                    w.nospace_on_build = true;
775                    if let Some((free, _)) = w.fs.get_mut("/nix") {
776                        *free = 0;
777                    }
778                    consumed.insert(i);
779                }
780                "edit_override_file" if state == "Published" => {
781                    self.fake.w().third_party_on_publish = true;
782                    consumed.insert(i);
783                }
784                "fill_fs" if get("fs") == "device:fs-boot" => {
785                    self.fake.w().fail_publish = true;
786                    consumed.insert(i);
787                }
788                "block_network" if get("dst") == "127.0.0.1" => {
789                    self.fake.w().block_on_activate.insert(UnitId::service("sshd"));
790                    consumed.insert(i);
791                }
792                _ => {}
793            }
794        }
795        (consumed, crash)
796    }
797
798    pub fn set_crash(&mut self, p: Option<CrashPoint>) {
799        self.crash = p;
800    }
801
802    pub fn step(&mut self, s: &T) -> Result<(), Unsupported> {
803        let get = |k: &str| s.get(k).and_then(T::as_str).unwrap_or("");
804        for k in ["unit", "device"] {
805            if let Some(u) = s.get(k).and_then(T::as_str) {
806                self.touched.insert(unit(u));
807            }
808        }
809        let actor = s.get("actor").and_then(T::as_str).unwrap_or("user:1001").to_string();
810        if let Some(t) = s.get("transition").and_then(T::as_str) {
811            return self.transition(t, s, &actor);
812        }
813        match get("raw") {
814            "kill_unit" => {
815                let sig = match get("signal") {
816                    "SIGHUP" => 1,
817                    "SIGINT" => 2,
818                    "SIGABRT" => 6,
819                    "SIGSEGV" => 11,
820                    "SIGTERM" => 15,
821                    _ => 9,
822                };
823                self.fake.w().signal(&unit(get("unit")), sig)
824            }
825            "oom" => self.fake.w().oom(&unit(get("unit"))),
826            // §12d, fake only: the kernel attributes a change to a unit (or
827            // to nobody: "unattributed"), as netwatch would report it.
828            "footprint" => {
829                use planarian_backend_netwatch::{Change, ChangeKind, Owner};
830                let kind = match get("change") {
831                    "interface" => ChangeKind::Interface,
832                    "route" => ChangeKind::Route,
833                    "rule" => ChangeKind::Rule,
834                    "dns" => ChangeKind::Dns,
835                    "proxy" => ChangeKind::Proxy,
836                    "firewall" => ChangeKind::Firewall,
837                    c => return Err(Unsupported(format!("footprint change {c}"))),
838                };
839                let owner = match get("unit") {
840                    "unattributed" => Owner::Unattributed,
841                    u => Owner::Unit(unit(u)),
842                };
843                let iface = s.get("interface").and_then(T::as_str).map(str::to_string);
844                let object = format!("{} {}", get("change"), iface.as_deref().unwrap_or("-"));
845                let steers = s.get("steers").and_then(T::as_bool).unwrap_or(false);
846                self.fake.w().netwatch.apply(&Change { owner, kind, object, iface, steers, removed: false });
847            }
848            // Fake only: that unit's footprint has gone stale.
849            "footprint_stale" => {
850                self.fake.w().netwatch_stale.insert(unit(get("unit")));
851            }
852            // Fake only: the watch failed to attach (`state = "attach-failed"`).
853            "netwatch" => match get("state") {
854                "attach-failed" => self.fake.w().netwatch_failed = Some("attach failed (scripted)".into()),
855                "attached" => self.fake.w().netwatch_failed = None,
856                st => return Err(Unsupported(format!("netwatch state {st}"))),
857            },
858            // A flaw in a generation that shows only once it is live (§12f
859            // gym cases: a committed update that later breaks a unit).
860            "break_in_generation" => {
861                let g = match get("generation") {
862                    "candidate" => {
863                        self.candidate.ok_or_else(|| Unsupported("break_in_generation: no candidate".into()))?
864                    }
865                    "base" => self.base,
866                    g => return Err(Unsupported(format!("break_in_generation {g}"))),
867                };
868                let mut w = self.fake.w();
869                w.break_in(g, &unit(get("unit")));
870                // Units a switch away from it also restarts (what its dry
871                // activation reports), e.g. the display manager.
872                for u in s.get("switch_restarts").and_then(T::as_array).into_iter().flatten().filter_map(T::as_str) {
873                    w.activation_restarts.insert(unit(u));
874                }
875            }
876            // Units every switch away from the current generation restarts
877            // (what its dry activation reports); fake only.
878            "restarts_on_switch" => {
879                let mut w = self.fake.w();
880                for u in s.get("units").and_then(T::as_array).into_iter().flatten().filter_map(T::as_str) {
881                    w.activation_restarts.insert(unit(u));
882                }
883            }
884            "stop_unit_out_of_band" => self.fake.w().stop_out_of_band(&unit(get("unit"))),
885            "set_knob" | "set_persistent_knob" => self.knob(&unit(get("unit")), get("knob"), s.get("value"))?,
886            "corrupt_state" => {
887                if let Some(sim) = self.fake.w().units.get_mut(&unit(get("unit"))) {
888                    sim.fail_on_start = true;
889                }
890            }
891            "unbind_driver" => {
892                self.fake.w().devices.insert(unit(get("device")), None);
893            }
894            "block_network" => match (get("dst"), s.get("port").and_then(T::as_integer)) {
895                ("127.0.0.1", Some(22)) => {
896                    self.fake.w().blocked.insert(UnitId::service("sshd"));
897                }
898                // Dropped, as the VM's iptables DROP does: probes time out.
899                ("10.0.2.2", None) => {
900                    self.fake.w().external_unreachable.insert("gym-upstream".into());
901                }
902                (d, p) => return Err(Unsupported(format!("block_network {d} {p:?}"))),
903            },
904            "unblock_network" => {
905                let mut w = self.fake.w();
906                w.blocked.clear();
907                w.external_unreachable.clear();
908            }
909            // An access point goes away: NetworkManager drops the network
910            // and does not pick another (autoconnect is off for the others).
911            "stop_host_service" if get("name").starts_with("hostapd-") => {
912                let ssid = get("name").trim_start_matches("hostapd-").to_string();
913                let mut w = self.fake.w();
914                w.wifi_in_range.remove(&ssid);
915                if w.runtime.get("wifi.network") == Some(&Value::Str(ssid)) {
916                    w.runtime.remove("wifi.network");
917                }
918            }
919            "stop_host_service" if get("name") == "gym-upstream" => {
920                self.fake.w().external_down.insert("gym-upstream".into());
921            }
922            "start_host_service" if get("name") == "gym-upstream" => {
923                self.fake.w().external_down.remove("gym-upstream");
924            }
925            "kill_process" => match get("process") {
926                "planariad" => {
927                    if s.get("when").is_some() {
928                        // Armed by `arm`; if it has not fired yet, wait for it.
929                        self.until(Millis::secs(600), |sim| sim.core.is_none());
930                    } else {
931                        self.kill_daemon();
932                    }
933                }
934                "planarian-recovery" => {
935                    self.fake.w().alive.remove(&WATCHDOG_PID);
936                }
937                p => return Err(Unsupported(format!("kill_process {p}"))),
938            },
939            "hold_down" => self.held = true,
940            // I18: edit a retained record's bytes in place, CRC recomputed, so
941            // only the hash chain can tell (the next record's `prev`).
942            "break_journal_chain" => {
943                if get("when") == "powered-off" {
944                    self.kill_daemon();
945                }
946                let n = Journal::open(self.fake.store.clone())
947                    .and_then(|mut j| j.segment())
948                    .map(|seg| seg.len())
949                    .map_err(|e| Unsupported(format!("break_journal_chain: {e:?}")))?;
950                if n < 3 {
951                    return Err(Unsupported("break_journal_chain: fewer than 3 records".into()));
952                }
953                let edited = planarian_txn::journal::tamper(&self.fake.store, n / 2, |p| {
954                    if let Some(b) = p.iter_mut().find(|b| b.is_ascii_digit()) {
955                        *b = if *b == b'9' { b'1' } else { *b + 1 };
956                    }
957                });
958                if !matches!(edited, Ok(true)) {
959                    return Err(Unsupported(format!("break_journal_chain: {edited:?}")));
960                }
961            }
962            // I18: delete a retained record (not edit it), the rest re-framed
963            // so every frame is intact; only the chain can tell.
964            "delete_journal_record" => {
965                let n = s.get("record").and_then(T::as_integer).unwrap_or(1) as usize;
966                let gone = planarian_txn::journal::delete(&self.fake.store, n)
967                    .map_err(|e| Unsupported(format!("delete_journal_record: {e}")))?;
968                if !gone {
969                    return Err(Unsupported(format!("delete_journal_record: no record {n}")));
970                }
971            }
972            // `sudo planarian-ack` (nix/posture.nix): an administrator's clears
973            // the alarm, journaled with identity; anyone else's is refused.
974            "ack_alarm" => {
975                if peer(&actor).admin {
976                    self.posture_seen.acks.push(actor.clone());
977                } else {
978                    self.posture_seen.refused_acks.push(actor.clone());
979                }
980            }
981            "release" | "restart_process" => match get("process") {
982                "planariad" => {
983                    self.held = false;
984                    // `systemctl restart`: a running daemon stops first.
985                    if get("raw") == "restart_process" && self.core.is_some() {
986                        self.kill_daemon();
987                    }
988                    if self.core.is_none() {
989                        self.start_daemon();
990                    }
991                }
992                "planarian-recovery" => {
993                    self.fake.w().alive.insert(WATCHDOG_PID);
994                }
995                p => return Err(Unsupported(format!("restart_process {p}"))),
996            },
997            "wait" => match s.get("for") {
998                Some(T::Table(t)) if t.contains_key("journal") => {
999                    let state = t["journal"].as_str().unwrap_or("").to_string();
1000                    self.until(Millis::secs(1800), |sim| sim.journal_reached(&state));
1001                }
1002                Some(v) => {
1003                    let d = dur(v).ok_or_else(|| Unsupported(format!("wait {v}")))?;
1004                    self.until(d, |_| false);
1005                }
1006                None => {}
1007            },
1008            "ask" => self.request(&actor, Request::Ask { phrase: get("phrase").into(), choice: None })?,
1009            // In the fake a peer's session comes only from the actor string,
1010            // so a gone peer is one without a session.
1011            "request_then_exit" => {
1012                let req: Request = serde_json::from_value(
1013                    serde_json::to_value(s.get("request").ok_or_else(|| Unsupported("request".into()))?)
1014                        .map_err(|e| Unsupported(e.to_string()))?,
1015                )
1016                .map_err(|e| Unsupported(format!("request_then_exit: {e}")))?;
1017                let actor = get("actor");
1018                let who = actor.split('@').next().unwrap_or("user:1000").to_string();
1019                self.request(&who, req)?;
1020            }
1021            // I14: a request carrying fields it must not set (a caller, a
1022            // sensitivity, affected units), parsed as the daemon parses it.
1023            "forge_proposal_fields" => {
1024                let r = s.get("request").and_then(T::as_table).ok_or_else(|| Unsupported("request".into()))?;
1025                let args: BTreeMap<String, Value> = r
1026                    .get("args")
1027                    .and_then(T::as_table)
1028                    .map(|a| a.iter().map(|(k, v)| (k.clone(), value(v))).collect())
1029                    .unwrap_or_default();
1030                let change = ChangeId(r.get("change").and_then(T::as_str).unwrap_or("").into());
1031                let mut line = serde_json::to_value(Request::Apply { change, args }).expect("serializes");
1032                let forged = serde_json::to_value(s.get("fields").cloned().unwrap_or(T::Table(Default::default())))
1033                    .map_err(|e| Unsupported(e.to_string()))?;
1034                if let (Some(o), Some(f)) = (line.as_object_mut(), forged.as_object()) {
1035                    o.extend(f.clone());
1036                }
1037                let req: Request =
1038                    serde_json::from_value(line).map_err(|e| Unsupported(format!("forge_proposal_fields: {e}")))?;
1039                self.request(&actor, req)?;
1040            }
1041            "agent_run" => {
1042                let name = get("name");
1043                let argv: Vec<String> = s
1044                    .get("argv")
1045                    .and_then(T::as_array)
1046                    .map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect())
1047                    .unwrap_or_default();
1048                let actor = s.get("as").and_then(T::as_str).unwrap_or("user:1001").to_string();
1049                self.request(&actor, Request::AgentRun { name: name.to_string(), argv })?;
1050            }
1051            "cli" => {
1052                let argv: Vec<String> = s
1053                    .get("argv")
1054                    .and_then(T::as_array)
1055                    .map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect())
1056                    .unwrap_or_default();
1057                // Present an approval held earlier (the hash it was issued for).
1058                // The admin connects to a network outside Planarian.
1059                if let ["internal", "wifi-connect", ssid] =
1060                    argv.iter().map(String::as_str).collect::<Vec<_>>().as_slice()
1061                {
1062                    self.fake.w().runtime.insert("wifi.network".into(), Value::Str(ssid.to_string()));
1063                    return Ok(());
1064                }
1065                if argv.first().map(String::as_str) == Some("internal")
1066                    && argv.get(1).map(String::as_str) == Some("present-token")
1067                {
1068                    for h in self.held_tokens.clone() {
1069                        self.request_peer(peer(&actor).at_terminal(), Request::Approve { hash: h })?;
1070                    }
1071                    return Ok(());
1072                }
1073                let req = self.cli(&argv)?;
1074                self.request(&actor, req)?;
1075            }
1076            "approve_pending" => {
1077                let pending: Vec<Hash> =
1078                    self.core.as_ref().map(|c| c.ex.parked.values().map(|p| p.bound_to).collect()).unwrap_or_default();
1079                if s.get("hold_token").and_then(T::as_bool) == Some(true) {
1080                    self.held_tokens.extend(pending);
1081                    return Ok(());
1082                }
1083                // As the step's actor, at a terminal unless `via = "no-tty"`.
1084                let actor = s.get("actor").and_then(T::as_str).unwrap_or("root").to_string();
1085                let tty = s.get("via").and_then(T::as_str) != Some("no-tty");
1086                for h in pending {
1087                    let p = if tty { peer(&actor).at_terminal() } else { peer(&actor) };
1088                    self.request_peer(p, Request::Approve { hash: h })?;
1089                }
1090            }
1091            "greeter_login" => {
1092                let uid = s.get("uid").and_then(T::as_integer).unwrap_or(1000) as u32;
1093                let now = self.now();
1094                self.fake.w().sessions.push(SessionInfo {
1095                    id: format!("s{uid}"),
1096                    uid,
1097                    class: "user".into(),
1098                    seat: Some("seat0".into()),
1099                    remote: false,
1100                    active: true,
1101                    idle: false,
1102                    created: now,
1103                });
1104                let sim = planarian_fake::UnitSim::active(now);
1105                for name in ["pipewire", "wireplumber"] {
1106                    self.fake.w().units.insert(
1107                        UnitId {
1108                            kind: UnitKind::Service,
1109                            name: name.into(),
1110                            scope: Scope::User { uid, session: None },
1111                        },
1112                        sim.clone(),
1113                    );
1114                }
1115                // The compositor brings the user's graphical session up.
1116                self.fake.w().units.insert(
1117                    UnitId {
1118                        kind: UnitKind::Target,
1119                        name: "graphical-session".into(),
1120                        scope: Scope::User { uid, session: None },
1121                    },
1122                    sim,
1123                );
1124            }
1125            // The machine restarts: the boot loader picks the entry (a one-shot
1126            // first, unless it fails), and the daemon starts on a fresh boot.
1127            "reboot" => {
1128                self.kill_daemon();
1129                self.fake.w().reboot();
1130                self.boots += 1;
1131                self.fresh_boot = true;
1132                self.start_daemon();
1133            }
1134            "fail_boot" => self.fake.w().fail_one_shot = true,
1135            "logout" => {
1136                let uid = s.get("uid").and_then(T::as_integer).unwrap_or(1000) as u32;
1137                self.fake.w().sessions.retain(|x| x.uid != uid);
1138            }
1139            // §10: a named logind session, at a seat or (no seat) remote; its
1140            // compositor comes up unless `compositor = "fails"`.
1141            "session_login" => {
1142                let uid = s.get("uid").and_then(T::as_integer).unwrap_or(1000) as u32;
1143                let id = get("session").to_string();
1144                let seat = s.get("seat").and_then(T::as_str).map(String::from);
1145                // An SSH-only login (no seat) starts no compositor.
1146                let compositor = seat.is_some() && get("compositor") != "fails";
1147                let now = self.now();
1148                let mut w = self.fake.w();
1149                w.sessions.push(SessionInfo {
1150                    id: id.clone(),
1151                    uid,
1152                    class: "user".into(),
1153                    remote: seat.is_none(),
1154                    seat,
1155                    active: true,
1156                    idle: false,
1157                    created: now,
1158                });
1159                w.compositors.insert(id, compositor);
1160                let sim = planarian_fake::UnitSim::active(now);
1161                for name in ["pipewire", "wireplumber"] {
1162                    let u = UnitId {
1163                        kind: UnitKind::Service,
1164                        name: name.into(),
1165                        scope: Scope::User { uid, session: None },
1166                    };
1167                    w.units.entry(u).or_insert_with(|| sim.clone());
1168                }
1169            }
1170            "session_logout" => {
1171                let id = get("session").to_string();
1172                let mut w = self.fake.w();
1173                w.sessions.retain(|x| x.id != id);
1174                w.compositors.remove(&id);
1175                // The user manager (and its services) ends with the uid's last session.
1176                let uids: BTreeSet<u32> = w.sessions.iter().map(|x| x.uid).collect();
1177                w.units.retain(|u, _| !matches!(u.scope, Scope::User { uid, .. } if !uids.contains(&uid)));
1178            }
1179            "foreground" => {
1180                let id = get("session").to_string();
1181                let on = s.get("on").and_then(T::as_bool).unwrap_or(true);
1182                let now = self.now();
1183                self.foreground_steps.push((now, id.clone(), on));
1184                let mut w = self.fake.w();
1185                if on {
1186                    w.foreground.insert(id);
1187                } else {
1188                    w.foreground.remove(&id);
1189                }
1190            }
1191            "slow_backend" if get("call") == "activate_live" => {
1192                let d = s.get("delay").and_then(dur).unwrap_or(Millis::secs(120));
1193                let uncancellable = s.get("uncancellable").and_then(T::as_bool).unwrap_or(false);
1194                self.fake.w().slow_activation = Some((d, !uncancellable));
1195            }
1196            "withhold_reply" if get("call") == "activate_live" => {
1197                self.fake.w().slow_activation = Some((Millis::secs(30), false));
1198            }
1199            "backend_result" => match (get("call"), get("result")) {
1200                ("jobs_tagged", "None") => self.fake.w().jobs_tagged_unknown = true,
1201                // A policy table changes (one no decision here reads), so the
1202                // policy version every approval is bound to changes.
1203                ("policy_version", "bumped") => {
1204                    let c =
1205                        self.core.as_mut().ok_or_else(|| Unsupported("policy bump while the daemon is down".into()))?;
1206                    c.ex.cfg.tables.model_catalog.push(ModelId("gym:policy-bump".into()));
1207                }
1208                ("evaluate", "planariad-unit-changed") => {
1209                    self.fake.w().rules.push(planarian_fake::Rule {
1210                        option: "planarian.reference.fontScale".into(),
1211                        value: Value::Str("1.25".into()),
1212                        effect: planarian_fake::Effect::TouchCore,
1213                    });
1214                }
1215                ("dry_activate", r @ ("exit-nonzero" | "timeout" | "truncated")) => {
1216                    self.fake.w().dry_activate_fails = Some(format!("dry activation: {r}"));
1217                }
1218                ("verify_candidate", "nar-mismatch") => {
1219                    self.fake.w().nar_mismatch = true;
1220                }
1221                ("activate_live", "Failed") => {
1222                    let base = self.base;
1223                    self.fake.w().fail_activate.insert(base);
1224                }
1225                (c, r) => return Err(Unsupported(format!("backend_result {c} {r}"))),
1226            },
1227            "edit_owner_source" => {
1228                let path = get("path").to_string();
1229                let tracked = s.get("tracked").and_then(T::as_bool).unwrap_or(true);
1230                if tracked {
1231                    let mut w = self.fake.w();
1232                    w.owner_files.insert(path, "edited".into());
1233                    // An edit that enables a service, not yet rebuilt: the
1234                    // owner's tree (what staging copies) now declares it.
1235                    if let Some(svc) = s.get("enables").and_then(T::as_str) {
1236                        w.base_options.insert(format!("services.{svc}.enable"), Value::Bool(true));
1237                    }
1238                } else {
1239                    self.fake.w().untracked.insert(path);
1240                }
1241            }
1242            // Suspended: time passes and nothing runs, the daemon included;
1243            // on resume its evidence is stale and it ticks on as before.
1244            "suspend" => {
1245                let d = s.get("duration").and_then(dur).ok_or_else(|| Unsupported("suspend duration".into()))?;
1246                self.fake.advance(d);
1247            }
1248            // §12h: only the wall clock moves; the boot clock does not.
1249            "set_wall_clock" => {
1250                let by = get("by");
1251                let (neg, rest) = match by.strip_prefix('-') {
1252                    Some(r) => (true, r.to_string()),
1253                    None => (false, by.trim_start_matches('+').to_string()),
1254                };
1255                let d = dur(&T::String(rest)).ok_or_else(|| Unsupported(format!("set_wall_clock {by}")))?;
1256                let ms = d.0 as i64;
1257                self.fake.w().wall_skew += if neg { -ms } else { ms };
1258            }
1259            "advance_clock" => {
1260                let d = s.get("by").and_then(dur).ok_or_else(|| Unsupported("advance_clock".into()))?;
1261                self.fake.advance(d);
1262                if let Some(c) = self.core.as_mut() {
1263                    c.tick();
1264                }
1265            }
1266            // A checkpoint write is torn: the compaction fails, and the old
1267            // checkpoint and log must stand.
1268            "interrupt_write" if get("target") == "checkpoint" => {
1269                self.save_segment();
1270                self.compacted = true;
1271                self.fake.store.fail_next_checkpoint();
1272                if let Some(c) = self.core.as_mut() {
1273                    let _ = c.ex.kernel.journal.compact();
1274                } else {
1275                    let _ = Journal::open(self.fake.store.clone()).map(|mut j| j.compact());
1276                }
1277            }
1278            // §12c (C16): a checkpoint whose bytes were damaged while the
1279            // daemon was down; its CRC no longer matches.
1280            "corrupt_checkpoint" => {
1281                self.kill_daemon();
1282                let mut b = self
1283                    .fake
1284                    .store
1285                    .read_checkpoint()
1286                    .ok()
1287                    .flatten()
1288                    .ok_or_else(|| Unsupported("corrupt_checkpoint: no checkpoint".into()))?;
1289                let mid = b.len() / 2;
1290                b[mid] ^= 0x20;
1291                self.fake.store.write_checkpoint(&b).map_err(|e| Unsupported(e.to_string()))?;
1292            }
1293            "force_compaction" => {
1294                self.save_segment();
1295                self.compacted = true;
1296                if let Some(c) = self.core.as_mut() {
1297                    let _ = c.ex.kernel.journal.compact();
1298                } else {
1299                    let _ = Journal::open(self.fake.store.clone()).map(|mut j| j.compact());
1300                }
1301            }
1302            "hotunplug" => {
1303                self.fake.w().devices.remove(&unit(get("device")));
1304            }
1305            "fill_fs" => {
1306                let path = match get("fs") {
1307                    "device:fs-nix" => "/nix",
1308                    "device:fs-boot" => "/boot",
1309                    "device:fs-journal" => "/var/lib/planarian",
1310                    _ => "/",
1311                };
1312                if get("until") == "full" && path == "/boot" {
1313                    self.fake.w().fail_publish = true;
1314                }
1315                if let Some((free, size)) = self.fake.w().fs.get_mut(path) {
1316                    *free = if get("until") == "full" { 0 } else { *size / 100 };
1317                }
1318            }
1319            "set_setting" => match get("key") {
1320                "gc.retain_all" => {
1321                    self.fake.w().gc_budget.retain_all = s.get("value").and_then(T::as_bool).unwrap_or(true);
1322                }
1323                "gc.generation_budget_percent" => {
1324                    let pct =
1325                        s.get("value").and_then(T::as_float).ok_or_else(|| Unsupported("set_setting value".into()))?;
1326                    self.fake.w().gc_budget.generations_permille = (pct * 10.0).round() as u32;
1327                }
1328                "userControllable" => {
1329                    let units: Vec<String> = s
1330                        .get("value")
1331                        .and_then(T::as_array)
1332                        .into_iter()
1333                        .flatten()
1334                        .filter_map(T::as_str)
1335                        .map(|u| unit(u).name)
1336                        .collect();
1337                    let c = self.core.as_mut().ok_or_else(|| Unsupported("daemon down".into()))?;
1338                    c.ex.cfg.settings.user_controllable_units.extend(units);
1339                }
1340                "userControllableChanges" => {
1341                    let add: Vec<ChangeId> = s
1342                        .get("value")
1343                        .and_then(T::as_array)
1344                        .into_iter()
1345                        .flatten()
1346                        .filter_map(T::as_str)
1347                        .map(|c| ChangeId(c.into()))
1348                        .collect();
1349                    let c = self.core.as_mut().ok_or_else(|| Unsupported("daemon down".into()))?;
1350                    c.ex.cfg.settings.user_controllable_changes.extend(add);
1351                }
1352                "max_tier" => {
1353                    let tier = match s.get("value").and_then(T::as_str) {
1354                        Some("Observe") => Tier::Observe,
1355                        Some("Runtime") => Tier::Runtime,
1356                        Some("Tested") => Tier::Tested,
1357                        v => return Err(Unsupported(format!("set_setting max_tier {v:?}"))),
1358                    };
1359                    let c = self.core.as_mut().ok_or_else(|| Unsupported("daemon down".into()))?;
1360                    c.ex.cfg.settings.max_tier = tier;
1361                }
1362                "posture" => {
1363                    self.posture = match s.get("value").and_then(T::as_str) {
1364                        Some("Relaxed") => planarian_schema::Posture::Relaxed,
1365                        Some("Guarded") => planarian_schema::Posture::Guarded,
1366                        Some("Sealed") => planarian_schema::Posture::Sealed,
1367                        _ => planarian_schema::Posture::Standard,
1368                    };
1369                    let p = self.posture;
1370                    if let Some(c) = self.core.as_mut() {
1371                        c.set_posture(p);
1372                    }
1373                }
1374                k => return Err(Unsupported(format!("set_setting {k}"))),
1375            },
1376            "out_of_band_rebuild" => {
1377                let edit = get("edit");
1378                let activate = s.get("activate").and_then(T::as_bool).unwrap_or(false);
1379                let mut w = self.fake.w();
1380                if let Some(n) = edit
1381                    .strip_prefix("create ")
1382                    .and_then(|r| r.split_whitespace().next())
1383                    .and_then(|n| n.parse::<usize>().ok())
1384                {
1385                    for _ in 0..n {
1386                        let o = OverrideSet::default();
1387                        let g = w.add_generation(&o);
1388                        w.boot_entries.insert(g);
1389                    }
1390                } else if edit == "build-only NetworkManager store path" {
1391                    // R04: equal manifests; only the network unit's store
1392                    // path differs, which only a dry activation sees.
1393                    let g = w.add_generation(&OverrideSet::default());
1394                    let nm = UnitId::service("NetworkManager");
1395                    w.gens.get_mut(&g).expect("added").eval.activation.restart.insert(nm);
1396                    w.boot_entries.insert(g);
1397                } else if edit.contains("gym-web ExecStart=/bin/false") {
1398                    let o = OverrideSet::default();
1399                    let g = w.add_generation(&o);
1400                    w.gens.get_mut(&g).expect("added").broken.insert(UnitId::service("gym-web"));
1401                    if activate {
1402                        // The owner switched to it themselves: it is their generation now.
1403                        w.boot_entries.insert(g);
1404                        w.boot_default = g;
1405                        let sim = w.units.get_mut(&UnitId::service("gym-web")).expect("gym-web");
1406                        sim.observed = Observed::Failed(Reason::ExitCode(1));
1407                        w.current = g;
1408                    }
1409                } else if edit.starts_with("update input gym-modules") {
1410                    // The supply-chain path: the input moves to a revision
1411                    // whose module declares the payload; root switches.
1412                    w.base_options.insert("gym.modules".into(), Value::Str("new".into()));
1413                    w.base_inputs.insert("gym-modules".into(), ("new".into(), "/nix/store/bbb-gym-modules".into()));
1414                    w.rebuild_uid = if actor == "root" { 0 } else { peer(&actor).uid };
1415                    let g = w.add_generation(&OverrideSet::default());
1416                    w.rebuild_uid = 0;
1417                    w.boot_entries.insert(g);
1418                    w.boot_default = g;
1419                    w.apply_generation(g);
1420                } else if edit == "enable gym-bad-payload" {
1421                    // The owner's configuration now enables it; root switches.
1422                    w.base_options.insert("services.gym-bad-payload.enable".into(), Value::Bool(true));
1423                    w.rebuild_uid = if actor == "root" { 0 } else { peer(&actor).uid };
1424                    let g = w.add_generation(&OverrideSet::default());
1425                    w.rebuild_uid = 0;
1426                    w.boot_entries.insert(g);
1427                    w.boot_default = g;
1428                    w.apply_generation(g);
1429                } else {
1430                    return Err(Unsupported(format!("out_of_band_rebuild {edit:?}")));
1431                }
1432            }
1433            other => return Err(Unsupported(format!("raw action {other:?}"))),
1434        }
1435        self.harvest();
1436        Ok(())
1437    }
1438
1439    fn knob(&mut self, u: &UnitId, knob: &str, v: Option<&T>) -> Result<(), Unsupported> {
1440        let mut w = self.fake.w();
1441        let now = w.now;
1442        let sim = w.units.get_mut(u).ok_or_else(|| Unsupported(format!("no unit {u}")))?;
1443        match (knob, v) {
1444            ("health", Some(T::Integer(503))) => sim.health = Health::Http503,
1445            ("health_delay", Some(d)) => sim.health = Health::Delay(dur(d).unwrap_or(Millis::secs(15))),
1446            ("probe_delay", Some(d)) => sim.health = Health::Stall(dur(d).unwrap_or(Millis::secs(15))),
1447            ("fail_after", Some(d)) => {
1448                sim.fail_after = dur(d);
1449                sim.started_at = now;
1450            }
1451            ("fail_on_start", Some(T::Boolean(b))) => sim.fail_on_start = *b,
1452            (k, v) => return Err(Unsupported(format!("knob {k}={v:?}"))),
1453        }
1454        Ok(())
1455    }
1456
1457    fn cli(&self, argv: &[String]) -> Result<Request, Unsupported> {
1458        let a: Vec<&str> = argv.iter().map(String::as_str).collect();
1459        Ok(match a.as_slice() {
1460            ["override", u] => Request::SetOverride {
1461                unit: unit(u),
1462                until: Until::Explicit,
1463                desired: planarian_schema::OverrideDesired::LeaveAlone,
1464            },
1465            ["override", u, "--stop" | "--stopped"] => Request::SetOverride {
1466                unit: unit(u),
1467                until: Until::Explicit,
1468                desired: planarian_schema::OverrideDesired::Stopped,
1469            },
1470            ["stop", u] => Request::Stop { unit: unit(u) },
1471            ["restart", u] => Request::Restart { unit: unit(u) },
1472            ["ack"] => Request::Acknowledge { unit: None },
1473            ["ack", u] => Request::Acknowledge { unit: Some(unit(u)) },
1474            ["release", u] => Request::LiftOverride { unit: unit(u) },
1475            ["resolve", _t, how] => {
1476                let l = self.ledger();
1477                // `resolve any`: the interlocked transaction, or with none
1478                // interlocked, one that does not exist (authorization is
1479                // decided before the transaction is looked up).
1480                let txn = match (l.interlock.keys().next(), *_t) {
1481                    (Some(t), _) => *t,
1482                    (None, "any") => TxnId(u64::MAX),
1483                    (None, _) => return Err(Unsupported("resolve: nothing interlocked".into())),
1484                };
1485                let resolution = match *how {
1486                    "--as-committed" => Resolution::AsCommitted,
1487                    "--as-reverted" => Resolution::AsReverted,
1488                    _ => Resolution::ManualRepaired,
1489                };
1490                Request::Resolve { txn, resolution }
1491            }
1492            ["diagnose", u, "--probe", p] => Request::Diagnose { unit: unit(u), probe: Some(ProbeId(p.to_string())) },
1493            ["undo"] => Request::Undo,
1494            other => return Err(Unsupported(format!("cli {other:?}"))),
1495        })
1496    }
1497
1498    fn transition(&mut self, t: &str, s: &T, actor: &str) -> Result<(), Unsupported> {
1499        let get = |k: &str| s.get(k).and_then(T::as_str).unwrap_or("").to_string();
1500        let req = match t {
1501            "ApplyChange" => {
1502                let args: BTreeMap<String, Value> = s
1503                    .get("args")
1504                    .and_then(T::as_table)
1505                    .map(|a| a.iter().map(|(k, v)| (k.clone(), value(v))).collect())
1506                    .unwrap_or_default();
1507                Request::Apply { change: ChangeId(get("change")), args }
1508            }
1509            "RestartUnit" if s.get("expected").is_some() || s.get("observed").is_some() => {
1510                let core = self.core.as_mut().ok_or_else(|| Unsupported("daemon down".into()))?;
1511                let (st, defs) = core.observe();
1512                let t = Transition::RestartUnit(unit(&get("unit")));
1513                let mut expected = planarian_schema::spec::predict(&t, &st, &defs);
1514                if let Some(m) = s.get("expected").and_then(|e| e.get("may_change")).and_then(T::as_array) {
1515                    expected.may_change = m.iter().filter_map(T::as_str).map(unit).collect();
1516                }
1517                let observed = if s.get("observed").is_some() { Hash::ZERO } else { st.digest() };
1518                let p = Proposal { observed, expected, transition: t, reason: "gym".into(), source: Source::User };
1519                let pe = peer(actor);
1520                let authz = gym_authz(actor);
1521                let policy = core.ex.policy_version();
1522                let r = core.ex.submit(p.clone(), authz, &st, &defs);
1523                let (facts, decision) = core.ex.last.clone().unzip();
1524                let now = self.fake.w().now;
1525                self.log.push(planarian_daemon::LogRecord {
1526                    at: now,
1527                    proposal: p,
1528                    caller: Actor::user(pe.uid),
1529                    pre_digest: st.digest(),
1530                    policy,
1531                    result: r,
1532                    facts,
1533                    decision,
1534                    post_digest: None,
1535                    path: vec![],
1536                    why: String::new(),
1537                    agent: None,
1538                    no_snapshot: false,
1539                    approver: None,
1540                    bound_to: None,
1541                });
1542                return Ok(());
1543            }
1544            "RestartUnit" => Request::Restart { unit: unit(&get("unit")) },
1545            "CollectGarbage" => Request::Gc,
1546            "Rollback" => return self.direct(actor, Transition::Rollback),
1547            // Restoring a snapshot overwrites user data (NonReversible).
1548            "RestoreSnapshot" => {
1549                let id = SnapshotId(get("snapshot"));
1550                return self.direct(actor, Transition::RestoreSnapshot(id));
1551            }
1552            "SwitchGeneration" => {
1553                let g = self.fake.w().gens.keys().copied().max().unwrap_or(GenerationId(1));
1554                self.candidate = Some(g);
1555                return self.direct(actor, Transition::SwitchGeneration(g));
1556            }
1557            "BindDriver" => {
1558                let device = unit(&get("device"));
1559                let driver = DriverRef { name: get("driver"), module: None };
1560                return self.direct(actor, Transition::BindDriver { device, driver });
1561            }
1562            "SetOverride" => Request::SetOverride {
1563                unit: unit(&get("unit")),
1564                until: Until::Explicit,
1565                desired: planarian_schema::OverrideDesired::LeaveAlone,
1566            },
1567            other => return Err(Unsupported(format!("transition {other}"))),
1568        };
1569        self.request(actor, req)
1570    }
1571
1572    /// Submit through the executor directly, for transitions the CLI doesn't expose.
1573    fn direct(&mut self, actor: &str, t: Transition) -> Result<(), Unsupported> {
1574        let crash = self.crash.clone();
1575        let core = self.core.as_mut().ok_or_else(|| Unsupported("daemon down".into()))?;
1576        core.ex.kernel.crash_at = crash;
1577        let (st, defs) = core.observe();
1578        let p = Proposal {
1579            observed: st.digest(),
1580            expected: planarian_schema::spec::predict(&t, &st, &defs),
1581            transition: t,
1582            reason: "gym".into(),
1583            source: Source::User,
1584        };
1585        let pe = peer(actor);
1586        let authz = gym_authz(actor);
1587        let policy = core.ex.policy_version();
1588        let r = core.ex.submit(p.clone(), authz, &st, &defs);
1589        let (facts, decision) = core.ex.last.clone().unzip();
1590        if let Some(c) = self.core.as_mut() {
1591            c.ex.kernel.crash_at = None;
1592        }
1593        let crashed = matches!(&r, planarian_exec::Submitted::Error(e) if e.contains("Crashed"));
1594        let now = self.fake.w().now;
1595        self.log.push(planarian_daemon::LogRecord {
1596            at: now,
1597            proposal: p,
1598            caller: Actor::user(pe.uid),
1599            pre_digest: st.digest(),
1600            policy,
1601            result: r,
1602            facts,
1603            decision,
1604            post_digest: None,
1605            path: vec![],
1606            why: String::new(),
1607            agent: None,
1608            no_snapshot: false,
1609            approver: None,
1610            bound_to: None,
1611        });
1612        if crashed {
1613            self.kill_daemon();
1614            self.crash = None;
1615        }
1616        Ok(())
1617    }
1618
1619    /// Run ticks for the rest of the window, restarting the daemon unless held down.
1620    pub fn run_window(&mut self, window: Millis) {
1621        let end = self.start.plus(window);
1622        while self.now() < end {
1623            if self.core.is_none() && !self.held {
1624                self.start_daemon();
1625            }
1626            self.tick();
1627        }
1628    }
1629}