Skip to main content

planarian_fake/
lib.rs

1//! A fake machine: every backend trait, in memory, deterministic.
2//!
3//! It models what the catalog's fake-harness cases need. The NixOS side has
4//! generations, an owner source tree, the override file, a tiny evaluator
5//! (option rules that break, remove or alter units), builds, live activation,
6//! boot entries and GC. The systemd side has units that crash, flap, fail on
7//! start, or are restarted by systemd itself. Devices have drivers. logind
8//! has sessions. There are external dependencies, runtime options, and
9//! processes (owner and worker pids). Fencing reads the epochs in the shared
10//! journal store, as real backends read the journal's.
11//!
12//! Time advances only through `Clock::sleep` or `Fake::advance`.
13
14use planarian_changes::render;
15use planarian_schema::*;
16pub use planarian_txn::store::MemStore;
17use planarian_txn::store::Store;
18use std::collections::{BTreeMap, BTreeSet};
19use std::sync::{Arc, Mutex, MutexGuard};
20
21#[derive(Clone, Debug, PartialEq, Eq)]
22pub enum Effect {
23    /// The unit fails to start in generations where the rule holds.
24    Break(UnitId),
25    /// The unit is not part of such generations (disabled).
26    Remove(UnitId),
27    /// The evaluated unit files for planariad change (touches_core).
28    TouchCore,
29    /// The probe definition on a unit changes (a manifest edit).
30    ReplaceProbe(UnitId, ProbeDef),
31    /// The probe with this id is gone from the unit (removed, not weakened).
32    DropProbe(UnitId, String),
33    /// Kernel/boot change: needs a reboot to be confirmed.
34    NeedsReboot,
35    /// A system service the generation declares (watched or not), defined in
36    /// `file` (§15 provenance). `gym-bad-payload` is the catalog's payload:
37    /// it takes gym-web's port `PAYLOAD_DELAY` after it comes up.
38    Declare { service: String, file: String },
39    /// The package at this version, with or without nixpkgs'
40    /// `knownVulnerabilities` (§12e).
41    Package { name: String, version: String, insecure: bool },
42}
43
44/// An outcome injected into the next call of one name (the gym's
45/// `backend_result` and the assumptions conformance run): the effect lands
46/// but the call reports failure (Partial) or cannot say (Unknown), or the
47/// call is refused before any effect as if fenced.
48#[derive(Clone, Copy, Debug, PartialEq, Eq)]
49pub enum Injected {
50    Partial,
51    Unknown,
52    Fenced,
53}
54
55/// If option `option` evaluates to `value`, apply `effect`.
56#[derive(Clone, Debug, PartialEq, Eq)]
57pub struct Rule {
58    pub option: String,
59    pub value: Value,
60    pub effect: Effect,
61}
62
63#[derive(Clone, Debug, PartialEq, Eq)]
64pub enum Health {
65    Ok,
66    /// Http probes get a 503; the process stays active.
67    Http503,
68    /// The service answers requests after this long (Http probes); it still
69    /// accepts connections at once.
70    Delay(Millis),
71    /// Every network probe, connecting included, takes this long (the port
72    /// is unreachable rather than slow).
73    Stall(Millis),
74}
75
76#[derive(Clone, Debug)]
77pub struct UnitSim {
78    pub observed: Observed,
79    /// How the main process last ended (`ended_by`).
80    pub ended: Option<Ending>,
81    pub health: Health,
82    /// Exits this long after each start (flapping).
83    pub fail_after: Option<Millis>,
84    /// Fails every start (corrupt state file).
85    pub fail_on_start: bool,
86    /// systemd `Restart=` with this RestartSec.
87    pub systemd_restart: Option<Millis>,
88    pub started_at: Timestamp,
89    pub failed_at: Option<Timestamp>,
90    pub restarts: u32,
91    /// Restarts systemd made on its own (`NRestarts`).
92    pub auto_restarts: u32,
93}
94
95impl UnitSim {
96    pub fn active(now: Timestamp) -> UnitSim {
97        UnitSim {
98            observed: Observed::Active,
99            ended: None,
100            health: Health::Ok,
101            fail_after: None,
102            fail_on_start: false,
103            systemd_restart: None,
104            started_at: now,
105            failed_at: None,
106            restarts: 0,
107            auto_restarts: 0,
108        }
109    }
110}
111
112#[derive(Clone, Debug)]
113pub struct Gen {
114    pub path: StorePath,
115    pub eval: EvaluatedGeneration,
116    pub broken: BTreeSet<UnitId>,
117    /// The profile link's owner and time (`created_by`).
118    pub created: (u32, Timestamp),
119}
120
121/// How long the gym's bad payload behaves before it takes gym-web's port:
122/// past any generation check (§15 malicious declaration).
123pub const PAYLOAD_DELAY: Millis = Millis::secs(240);
124
125#[derive(Clone, Debug)]
126pub struct Job {
127    pub op: OpId,
128    pub done_at: Timestamp,
129    pub cancellable: bool,
130    pub activates: GenerationId,
131}
132
133#[derive(Clone, Debug, PartialEq, Eq)]
134pub struct Call {
135    pub at: Timestamp,
136    pub op: OpId,
137    pub call: String,
138    pub fenced: bool,
139}
140
141/// The op a call outside any transaction is recorded under (the hold).
142const NO_OP: OpId = OpId { txn: TxnId(0), epoch: 0, seq: 0 };
143
144#[derive(Debug, Default)]
145pub struct World {
146    pub now: Timestamp,
147    // ---- netwatch (§12d "Which units"), scripted ----
148    /// What the kernel attributed to whom (gym step `footprint`).
149    pub netwatch: planarian_backend_netwatch::Tracker,
150    /// The watch failed to attach, and why (gym step `netwatch`); `None`:
151    /// attached, confirmed on every read.
152    pub netwatch_failed: Option<String>,
153    /// Units whose footprint has gone stale (gym step `footprint_stale`).
154    pub netwatch_stale: BTreeSet<UnitId>,
155    /// Wall clock minus boot clock, in ms (`set_wall_clock`, §12h).
156    pub wall_skew: i64,
157    // ---- NixOS ----
158    /// The owner's configuration as evaluated without any override.
159    pub base_units: UnitDefs,
160    pub base_user_units: Vec<UnitDef>,
161    pub base_options: BTreeMap<String, Value>,
162    /// The base generation's packages (pname → version) and the insecure ones (§12e).
163    pub base_packages: BTreeMap<String, String>,
164    pub base_insecure: BTreeSet<String>,
165    /// Outcomes to inject, by call name (`restart`, `activate_live`, …), once each.
166    pub inject: BTreeMap<String, Injected>,
167    /// The owner flake's inputs, name → (rev, store path).
168    pub base_inputs: BTreeMap<String, (String, String)>,
169    /// Who the next out-of-band generation is created by (uid).
170    pub rebuild_uid: u32,
171    /// When the bad payload takes gym-web's port (set when a generation
172    /// declaring it is applied).
173    pub payload_at: Option<Timestamp>,
174    pub rules: Vec<Rule>,
175    /// Options the owner `mkForce`d.
176    pub forced: BTreeSet<String>,
177    pub core_units: BTreeMap<String, Hash>,
178    /// Tracked owner files (path → content) and untracked ones.
179    pub owner_files: BTreeMap<String, String>,
180    pub untracked: BTreeSet<String>,
181    pub gens: BTreeMap<GenerationId, Gen>,
182    pub current: GenerationId,
183    pub booted: GenerationId,
184    pub boot_default: GenerationId,
185    pub boot_entries: BTreeSet<GenerationId>,
186    pub overrides: OverrideSet,
187    pub override_store: BTreeMap<Hash, OverrideSet>,
188    pub gen_overrides: BTreeMap<GenerationId, Hash>,
189    pub staged: BTreeMap<String, (OverrideSet, BTreeMap<String, String>)>,
190    pub gc_roots: BTreeSet<String>,
191    pub collected: Vec<GenerationId>,
192    pub fail_publish: bool,
193    /// Drivers that bind but cannot drive the device.
194    pub bad_drivers: BTreeSet<String>,
195    /// Hooks for faults that happen during a transaction: the owner edits a
196    /// tracked file while staging; the store fills during the build; a port is
197    /// blocked when the candidate activates.
198    pub edit_on_stage: bool,
199    /// The owner adds this untracked file while staging (after the copy).
200    pub untracked_on_stage: Option<String>,
201    pub nospace_on_build: bool,
202    /// How many times `build` ran.
203    pub builds: usize,
204    /// Sources staged (§9 step 1), for admission-before-work checks (R09).
205    pub stages: usize,
206    pub block_on_activate: BTreeSet<UnitId>,
207    pub fail_activate: BTreeSet<GenerationId>,
208    pub slow_activation: Option<(Millis, bool)>,
209    pub jobs: BTreeMap<JobId, Job>,
210    /// When each job started, and when it finished or was cancelled.
211    pub job_spans: BTreeMap<JobId, (Job, Timestamp, Option<Timestamp>)>,
212    pub next_job: u64,
213    pub jobs_tagged_unknown: bool,
214    // ---- systemd / logind / devices ----
215    pub units: BTreeMap<UnitId, UnitSim>,
216    pub devices: BTreeMap<UnitId, Option<DriverRef>>,
217    pub sessions: Vec<SessionInfo>,
218    /// Whether the compositor runs in each session's cgroup (§9); a session
219    /// not listed falls back to the user's graphical-session target.
220    pub compositors: BTreeMap<String, bool>,
221    /// A generation published to boot once (§12f), and generations whose boot
222    /// fails (the gym's `fail_boot`).
223    pub one_shot: Option<GenerationId>,
224    pub fail_one_shot: bool,
225    /// I23: a third party rewrites the override file once the next boot
226    /// entry is published; the hash of what it wrote.
227    pub third_party_on_publish: bool,
228    /// The running instance of each agent's unit (I24), as the agent's
229    /// processes would show it in their cgroup.
230    pub agent_instances: BTreeMap<String, InstanceId>,
231    /// The next build's dry activation fails this way (R10).
232    pub dry_activate_fails: Option<String>,
233    /// §12d: network-security units whose failure holds traffic, as systemd's
234    /// `OnFailure=` on them starts `planarian-hold@<unit>` (nix/posture.nix).
235    pub hold_on_failure: BTreeSet<UnitId>,
236    /// The units whose traffic is held now (`/run/planarian/hold`).
237    pub held: BTreeSet<UnitId>,
238    /// Network tools the host declines to hold: not protecting (the hold
239    /// unit's condition skips, as for a tool with no footprint).
240    pub hold_declines: BTreeSet<UnitId>,
241    /// A hold's start takes this long (a slow or stuck `systemctl start`):
242    /// every hold call gives up after its wait, the fake clock advanced by
243    /// that much, and the hold lands later, as systemd's job would.
244    pub hold_hangs: Option<Millis>,
245    /// Like `hold_hangs`, for `lift_hold` alone (it takes precedence there):
246    /// the table check and the holds still answer.
247    pub lift_hangs: Option<Millis>,
248    /// Units whose hold job is wedged: starting their hold never finishes
249    /// (every `hold` for them times out, whatever its wait).
250    pub hold_wedged: BTreeSet<UnitId>,
251    /// Released holds and until when (`None`: the next boot), as the
252    /// `/run/planarian/hold-released` marker the hold script honours.
253    pub hold_released: BTreeMap<UnitId, Option<Timestamp>>,
254    /// Fault injection: the next hold or lift fails this way.
255    pub hold_fails: Option<String>,
256    /// Fault injection: something flushed the nftables ruleset, so the hold's
257    /// table is gone while holds are still in place (`/run` says held).
258    pub hold_table_flushed: bool,
259    /// Fault injection (§12d): the hold table cannot be checked (`nft`
260    /// missing or failing).
261    pub hold_table_unreadable: bool,
262    /// Fault injection (§12d): re-applying the hold fails (the table stays
263    /// missing) while this is set.
264    pub hold_reapply_fails: bool,
265    /// Fault injection (C6): the next candidate's store contents no longer
266    /// hash to the NAR hash recorded when it was built.
267    pub nar_mismatch: bool,
268    /// Each probe batch takes this long on the fake clock (P9's slow tick).
269    pub probe_batch_ms: u64,
270    /// Every candidate's activation also restarts these (a user's audio,
271    /// say), so it disturbs an active session (§12f).
272    pub activation_restarts: BTreeSet<UnitId>,
273    pub next_instance: u64,
274    pub third_party_override: Option<Hash>,
275    /// Units planted outside the generation (the boot report finds them).
276    pub foreign_units: Vec<String>,
277    /// Sessions in which foreground work holds (§10 quiet mode).
278    pub foreground: BTreeSet<String>,
279    pub power: Option<PowerSource>,
280    /// Probes of these units fail regardless (e.g. a port blocked by nftables).
281    pub blocked: BTreeSet<UnitId>,
282    /// External dependencies that are down, by name (`gym-upstream`).
283    pub external_down: BTreeSet<String>,
284    /// External dependencies whose traffic is dropped on the way (a dead
285    /// gateway): their probes, and their clients', time out.
286    pub external_unreachable: BTreeSet<String>,
287    /// How many probe batches ran (a status request must not run one).
288    pub probe_batches: u64,
289    /// Free bytes and size of filesystems, by path.
290    pub fs: BTreeMap<String, (u64, u64)>,
291    /// §12b bounds, and the last collection's report. Each generation's
292    /// closure counts as `GEN_BYTES`, nothing shared.
293    pub gc_budget: gc::GcBudget,
294    pub space_report: Option<gc::SpaceReport>,
295    pub runtime: BTreeMap<String, Value>,
296    /// Wi-Fi: the networks NetworkManager knows, and those in range.
297    pub wifi_known: Vec<String>,
298    pub wifi_in_range: BTreeSet<String>,
299    // ---- processes ----
300    pub alive: BTreeSet<u32>,
301    pub unkillable: BTreeSet<u32>,
302    pub calls: Vec<Call>,
303}
304
305impl World {
306    /// The fake store's NAR hash of a path.
307    pub fn nar_of(path: &StorePath) -> Hash {
308        Hash::of(path.0.as_bytes())
309    }
310
311    /// What switching from the current generation to `g` would do: the
312    /// dry activation the real backend runs, from the two evaluations.
313    pub fn dry_plan(&self, g: GenerationId) -> ActivationPlan {
314        let cur = &self.gens[&self.current];
315        let new = &self.gens[&g];
316        let mut plan = new.eval.activation.clone();
317        if g != self.current {
318            plan.restart.extend(self.activation_restarts.iter().cloned());
319        }
320        for u in new.eval.units.keys() {
321            match cur.eval.units.get(u) {
322                None => {
323                    plan.start.insert(u.clone());
324                }
325                Some(old) if old != &new.eval.units[u] || new.broken.contains(u) != cur.broken.contains(u) => {
326                    plan.restart.insert(u.clone());
327                }
328                _ => {}
329            }
330        }
331        for (k, v) in &new.eval.options {
332            if cur.eval.options.get(k) != Some(v) {
333                // An option change restarts the services of its module (`services.<name>.…`).
334                if let Some(svc) = k.strip_prefix("services.").and_then(|r| r.split('.').next()) {
335                    let id = UnitId::service(svc);
336                    if new.eval.units.contains_key(&id) {
337                        plan.restart.insert(id);
338                    }
339                }
340            }
341        }
342        for u in cur.eval.units.keys() {
343            if !new.eval.units.contains_key(u) {
344                plan.stop.insert(u.clone());
345            }
346        }
347        plan
348    }
349
350    fn key(id: &UnitId) -> UnitId {
351        let mut k = id.clone();
352        if let Scope::User { uid, .. } = k.scope {
353            k.scope = Scope::User { uid, session: None };
354        }
355        k
356    }
357
358    pub fn override_hash_of(set: &OverrideSet) -> Hash {
359        Hash::of(render(set).as_bytes())
360    }
361
362    fn source_hash_of(files: &BTreeMap<String, String>, overrides: &OverrideSet) -> Hash {
363        let mut bytes = serde_json::to_vec(files).expect("files serialize");
364        bytes.extend(render(overrides).as_bytes());
365        Hash::of(&bytes)
366    }
367
368    /// The fake evaluator.
369    pub fn evaluate(&self, overrides: &OverrideSet) -> (EvaluatedGeneration, BTreeSet<UnitId>) {
370        let mut options = self.base_options.clone();
371        for (k, v) in &overrides.assignments {
372            if !self.forced.contains(k) {
373                options.insert(k.clone(), v.clone());
374            }
375        }
376        let mut units = self.base_units.clone();
377        let mut core = self.core_units.clone();
378        let mut broken = BTreeSet::new();
379        let mut needs_reboot = false;
380        let mut packages = self.base_packages.clone();
381        let mut insecure = self.base_insecure.clone();
382        let mut declarations: BTreeMap<String, Vec<String>> = self
383            .base_units
384            .keys()
385            .filter(|u| u.kind == UnitKind::Service && u.scope == Scope::System)
386            .map(|u| (u.name.clone(), vec!["/etc/nixos/configuration.nix".to_string()]))
387            .collect();
388        for r in &self.rules {
389            if options.get(&r.option) != Some(&r.value) {
390                continue;
391            }
392            match &r.effect {
393                Effect::Break(u) => {
394                    broken.insert(u.clone());
395                }
396                Effect::Remove(u) => {
397                    units.remove(u);
398                    declarations.remove(&u.name);
399                }
400                Effect::Declare { service, file } => {
401                    declarations.entry(service.clone()).or_default().push(file.clone());
402                }
403                Effect::TouchCore => {
404                    core.insert("planariad.service".into(), Hash::of(b"changed"));
405                }
406                Effect::ReplaceProbe(u, p) => {
407                    if let Some(d) = units.get_mut(u) {
408                        d.probes.retain(|x| x.id != p.id);
409                        d.probes.push(p.clone());
410                    }
411                }
412                Effect::DropProbe(u, id) => {
413                    if let Some(d) = units.get_mut(u) {
414                        d.probes.retain(|x| x.id.0 != *id);
415                    }
416                }
417                Effect::NeedsReboot => needs_reboot = true,
418                Effect::Package { name, version, insecure: bad } => {
419                    packages.insert(name.clone(), version.clone());
420                    if *bad {
421                        insecure.insert(name.clone());
422                    } else {
423                        insecure.remove(name);
424                    }
425                }
426            }
427        }
428        let locked = overrides.assignments.keys().filter(|k| self.forced.contains(*k)).cloned().collect();
429        let eval = EvaluatedGeneration {
430            source: Hash::ZERO,
431            options,
432            units,
433            user_units: self.base_user_units.clone(),
434            activation: ActivationPlan { needs_reboot_to_confirm: needs_reboot, ..Default::default() },
435            locked,
436            core_units: core,
437            packages,
438            insecure,
439            declarations,
440            inputs: self.base_inputs.clone(),
441            services: BTreeMap::new(),
442        };
443        (eval, broken)
444    }
445
446    pub fn add_generation(&mut self, overrides: &OverrideSet) -> GenerationId {
447        let (eval, broken) = self.evaluate(overrides);
448        let id = GenerationId(self.gens.keys().map(|g| g.0).max().unwrap_or(0) + 1);
449        let path = StorePath(format!("/nix/store/{:03}-nixos-system-fake", id.0));
450        let created = (self.rebuild_uid, self.now);
451        self.gens.insert(id, Gen { path, eval, broken, created });
452        id
453    }
454
455    pub fn apply_generation(&mut self, g: GenerationId) {
456        let generation = self.gens[&g].clone();
457        let now = self.now;
458        for id in generation.eval.units.keys() {
459            if id.kind != UnitKind::Service || id.name.starts_with("external:") {
460                continue;
461            }
462            let sim = self.units.entry(id.clone()).or_insert_with(|| UnitSim::active(now));
463            if generation.broken.contains(id) || sim.fail_on_start {
464                sim.observed = Observed::Failed(Reason::ExitCode(1));
465                sim.ended = Some(Ending::Exited(1));
466                sim.failed_at = Some(now);
467            } else if sim.observed != Observed::Active {
468                sim.observed = Observed::Active;
469                sim.started_at = now;
470            }
471        }
472        // The bad payload starts with the generation and bides its time.
473        self.payload_at = if generation.eval.declarations.contains_key("gym-bad-payload") {
474            self.payload_at.or(Some(now.plus(PAYLOAD_DELAY)))
475        } else {
476            // Gone with its generation: the port is free again.
477            if self.payload_at == Some(Timestamp(u64::MAX))
478                && let Some(web) = self.units.get_mut(&UnitId::service("gym-web"))
479            {
480                web.fail_on_start = false;
481            }
482            None
483        };
484        // Units the generation no longer has are stopped and gone.
485        let gone: Vec<UnitId> = self
486            .units
487            .keys()
488            .filter(|u| {
489                u.scope == Scope::System && u.kind == UnitKind::Service && !generation.eval.units.contains_key(u)
490            })
491            .cloned()
492            .collect();
493        for u in gone {
494            self.units.get_mut(&u).expect("listed").observed = Observed::Absent;
495        }
496        self.current = g;
497    }
498
499    /// §12d, `OnFailure=`: a failed network-security unit's traffic is held,
500    /// unless an unexpired release marks it.
501    pub fn on_failure_holds(&mut self) {
502        let now = self.now;
503        let failed: Vec<UnitId> = self
504            .hold_on_failure
505            .iter()
506            .filter(|u| self.units.get(*u).is_some_and(|s| matches!(s.observed, Observed::Failed(_))))
507            .filter(|u| !self.hold_released.get(*u).is_some_and(|t| t.is_none_or(|t| t > now)))
508            .cloned()
509            .collect();
510        self.held.extend(failed);
511    }
512
513    /// Advance time-driven behaviour to `now`.
514    pub fn advance_to(&mut self, now: Timestamp) {
515        self.now = now;
516        // It takes gym-web's port: gym-web crashes and cannot start again.
517        if self.payload_at.is_some_and(|t| t <= now) {
518            self.payload_at = Some(Timestamp(u64::MAX));
519            if let Some(web) = self.units.get_mut(&UnitId::service("gym-web")) {
520                web.fail_on_start = true;
521                web.observed = Observed::Failed(Reason::ExitCode(1));
522                web.ended = Some(Ending::Signal(11));
523                web.failed_at = Some(now);
524            }
525        }
526        let done: Vec<JobId> = self.jobs.iter().filter(|(_, j)| j.done_at <= now).map(|(k, _)| k.clone()).collect();
527        for id in done {
528            let job = self.jobs.remove(&id).expect("listed");
529            if let Some(span) = self.job_spans.get_mut(&id) {
530                span.2 = Some(job.done_at);
531            }
532            self.apply_generation(job.activates);
533        }
534        let broken = self.gens.get(&self.current).map(|g| g.broken.clone()).unwrap_or_default();
535        for (id, sim) in self.units.iter_mut() {
536            if sim.observed == Observed::Active
537                && let Some(after) = sim.fail_after
538                && now.0 >= sim.started_at.0 + after.0
539            {
540                sim.observed = Observed::Failed(Reason::ExitCode(1));
541                sim.ended = Some(Ending::Exited(1));
542                sim.failed_at = Some(Timestamp(sim.started_at.0 + after.0));
543            }
544            if let (Observed::Failed(_), Some(rs), Some(at)) = (&sim.observed, sim.systemd_restart, sim.failed_at)
545                && now.0 >= at.0 + rs.0
546            {
547                sim.restarts += 1;
548                sim.auto_restarts += 1;
549                if sim.fail_on_start || broken.contains(id) {
550                    sim.failed_at = Some(now);
551                } else {
552                    sim.observed = Observed::Active;
553                    sim.started_at = now;
554                }
555            }
556        }
557        self.on_failure_holds();
558    }
559
560    /// `systemctl stop` outside Planarian: systemd also stops every unit that
561    /// `Requires=` it (transitively).
562    pub fn stop_out_of_band(&mut self, unit: &UnitId) {
563        let mut todo = vec![unit.clone()];
564        while let Some(u) = todo.pop() {
565            if let Some(sim) = self.units.get_mut(&u)
566                && sim.observed == Observed::Active
567            {
568                // A stop job: SIGTERM, then inactive.
569                sim.observed = Observed::Bound;
570                sim.ended = Some(Ending::Signal(15));
571            }
572            for (id, d) in &self.base_units {
573                if d.edges.iter().any(|e| e.to == u && e.kind == EdgeKind::Requires) {
574                    todo.push(id.clone());
575                }
576            }
577        }
578    }
579
580    /// SIGKILL a unit's main process.
581    pub fn kill(&mut self, unit: &UnitId) {
582        self.signal(unit, 9);
583    }
584
585    /// Send `sig` to a unit's main process, as `systemctl kill` does. SIGTERM,
586    /// SIGINT, SIGHUP and SIGPIPE are clean exits to systemd (inactive); any
587    /// other signal fails the unit.
588    /// A flaw in generation `g` that shows only once it is live: the unit
589    /// fails in `g` (and only there) from now on, and fails now if `g` runs.
590    pub fn break_in(&mut self, g: GenerationId, unit: &UnitId) {
591        let now = self.now;
592        if let Some(generation) = self.gens.get_mut(&g) {
593            generation.broken.insert(unit.clone());
594        }
595        if self.current == g
596            && let Some(sim) = self.units.get_mut(unit)
597        {
598            sim.observed = Observed::Failed(Reason::ExitCode(1));
599            sim.ended = Some(Ending::Exited(1));
600            sim.failed_at = Some(now);
601        }
602    }
603
604    pub fn signal(&mut self, unit: &UnitId, sig: i32) {
605        let now = self.now;
606        if let Some(sim) = self.units.get_mut(unit) {
607            sim.ended = Some(Ending::Signal(sig));
608            if matches!(sig, 1 | 2 | 13 | 15) {
609                sim.observed = Observed::Bound;
610            } else {
611                sim.observed = Observed::Failed(Reason::ExitCode(128 + sig));
612                sim.failed_at = Some(now);
613            }
614        }
615        self.on_failure_holds();
616    }
617
618    /// The kernel's OOM killer ends the unit (`Result=oom-kill`).
619    pub fn oom(&mut self, unit: &UnitId) {
620        let now = self.now;
621        if let Some(sim) = self.units.get_mut(unit) {
622            sim.ended = Some(Ending::OomKill);
623            sim.observed = Observed::Failed(Reason::ExitCode(137));
624            sim.failed_at = Some(now);
625        }
626    }
627
628    pub fn reboot(&mut self) {
629        // A one-shot entry boots once; one that fails to boot falls back to
630        // the default (the boot loader's own fallback).
631        self.booted = match self.one_shot.take() {
632            Some(g) if !self.fail_one_shot => g,
633            _ => self.boot_default,
634        };
635        let now = self.now;
636        for id in self.jobs.keys() {
637            if let Some(span) = self.job_spans.get_mut(id) {
638                span.2 = Some(now);
639            }
640        }
641        self.jobs.clear();
642        self.sessions.clear();
643        let g = self.booted;
644        for sim in self.units.values_mut() {
645            sim.observed = Observed::Bound;
646        }
647        self.apply_generation(g);
648    }
649
650    fn start(&mut self, id: &UnitId) -> Outcome {
651        let now = self.now;
652        let broken = self.gens.get(&self.current).is_some_and(|g| g.broken.contains(id));
653        let present =
654            self.gens.get(&self.current).is_none_or(|g| g.eval.units.contains_key(id) || id.scope != Scope::System);
655        if !present {
656            return Outcome::Failed(format!("{id} not found"));
657        }
658        let sim = self.units.entry(Self::key(id)).or_insert_with(|| UnitSim::active(now));
659        sim.restarts += 1;
660        if broken || sim.fail_on_start {
661            sim.observed = Observed::Failed(Reason::ExitCode(1));
662            sim.ended = Some(Ending::Exited(1));
663            sim.failed_at = Some(now);
664            return Outcome::Failed(format!("{id} failed to start"));
665        }
666        sim.observed = Observed::Active;
667        sim.health = Health::Ok;
668        sim.started_at = now;
669        Outcome::Succeeded
670    }
671}
672
673/// The handle every backend trait is implemented on. Cheap to clone; all
674/// clones share one world and one journal store.
675#[derive(Clone, Debug)]
676pub struct Fake {
677    pub world: Arc<Mutex<World>>,
678    pub store: MemStore,
679}
680
681impl Fake {
682    /// §12d: the footprints as `/run/planarian/footprint/` would hold them
683    /// for the network tools `tools`, on the boot clock (seconds): the same
684    /// [`planarian_backend_netwatch::FootprintView`] the Linux side reads.
685    pub fn footprint_view(&self, tools: &[UnitId]) -> planarian_backend_netwatch::FootprintView {
686        use planarian_backend_netwatch::{FRESH_SECS, WatchState, file_name};
687        let w = self.w();
688        let now = w.now.0 / 1000;
689        let watch = match &w.netwatch_failed {
690            None => WatchState::Attached { at: now },
691            Some(reason) => WatchState::Failed { reason: reason.clone() },
692        };
693        let mut view = w.netwatch.view(watch, tools, now);
694        for u in &w.netwatch_stale {
695            if let Some(f) = view.units.get_mut(&file_name(u)) {
696                f.at = now.saturating_sub(FRESH_SECS + 1);
697            }
698        }
699        view
700    }
701
702    pub fn new(world: World, store: MemStore) -> Fake {
703        Fake { world: Arc::new(Mutex::new(world)), store }
704    }
705
706    pub fn w(&self) -> MutexGuard<'_, World> {
707        self.world.lock().unwrap_or_else(|e| e.into_inner())
708    }
709
710    /// `hold_hangs`: a hold call (`systemctl start`/`stop`, `nft`) that
711    /// takes that long. Past `wait` the client gives up (`Unknown`, the
712    /// fake clock advanced by `wait`) and nothing lands; within it, the
713    /// call takes its time and goes on.
714    fn hung(&self, verb: &str, unit: &UnitId, wait: Millis) -> Option<Outcome> {
715        let wedged = verb == "hold" && self.w().hold_wedged.contains(unit);
716        let h = if wedged {
717            Millis(u64::MAX)
718        } else if verb == "lift_hold" && self.w().lift_hangs.is_some() {
719            self.w().lift_hangs?
720        } else {
721            self.w().hold_hangs?
722        };
723        if h.0 > wait.0 {
724            let now = self.w().now;
725            self.w().calls.push(Call { call: format!("{verb}({unit}) timed out"), op: NO_OP, at: now, fenced: false });
726            self.advance(wait);
727            return Some(Outcome::Unknown(format!("{verb} for {unit} did not finish")));
728        }
729        self.advance(h);
730        None
731    }
732
733    pub fn advance(&self, d: Millis) {
734        let mut w = self.w();
735        let t = w.now.plus(d);
736        w.advance_to(t);
737    }
738
739    fn admit(&self, w: &mut World, op: &OpId, call: String) -> bool {
740        let fenced = self.store.fence(op.txn).ok().flatten() != Some(op.epoch);
741        w.calls.push(Call { at: w.now, op: *op, call, fenced });
742        !fenced
743    }
744
745    /// One mutating backend call: fenced on a stale epoch before any effect
746    /// (docs/assumptions.md), and subject to an injected outcome
747    /// (`World::inject`, keyed by the call's name before its parenthesis).
748    fn run(&self, op: &OpId, call: String, f: impl FnOnce(&mut World) -> Outcome) -> Outcome {
749        let mut w = self.w();
750        let name = call.split('(').next().unwrap_or("").to_string();
751        let injected = w.inject.remove(&name);
752        if injected == Some(Injected::Fenced) {
753            let at = w.now;
754            w.calls.push(Call { at, op: *op, call, fenced: true });
755            return Outcome::fenced();
756        }
757        if !self.admit(&mut w, op, call) {
758            return Outcome::fenced();
759        }
760        let out = f(&mut w);
761        match injected {
762            Some(Injected::Partial) => Outcome::Failed("partially applied (injected)".into()),
763            Some(Injected::Unknown) => Outcome::Unknown("no answer (injected)".into()),
764            _ => out,
765        }
766    }
767}
768
769impl UnitBackend for Fake {
770    /// §12d: the scripted footprints (`footprint_view`), read as the Linux
771    /// side reads the files.
772    fn protecting(&self, tools: &[UnitId]) -> Result<BTreeSet<UnitId>, Outcome> {
773        let now = self.w().now.0 / 1000;
774        Ok(self.footprint_view(tools).protecting(tools, now))
775    }
776    fn observe(&self, id: &UnitId) -> Result<Observed, Outcome> {
777        let w = self.w();
778        Ok(w.units.get(&World::key(id)).map(|s| s.observed.clone()).unwrap_or(Observed::Absent))
779    }
780
781    fn ended_by(&self, id: &UnitId) -> Option<Ending> {
782        self.w().units.get(&World::key(id)).and_then(|s| s.ended)
783    }
784
785    fn auto_restarts(&self, id: &UnitId) -> Option<u32> {
786        self.w().units.get(&World::key(id)).map(|s| s.auto_restarts)
787    }
788
789    fn foreign_units(&self) -> Vec<String> {
790        self.w().foreign_units.clone()
791    }
792
793    fn list(&self) -> Result<Vec<UnitId>, Outcome> {
794        Ok(self.w().units.keys().cloned().collect())
795    }
796
797    fn start_agent(
798        &self,
799        name: &str,
800        _argv: &[String],
801        _limits: &planarian_schema::backend::AgentLimits,
802    ) -> Result<InstanceId, Outcome> {
803        let mut w = self.w();
804        let now = w.now;
805        let id = UnitId::service(&format!("planarian-agent-{name}"));
806        w.units.insert(World::key(&id), UnitSim::active(now));
807        // A fresh InvocationID per start, as systemd gives one.
808        w.next_instance += 1;
809        let mut b = [0u8; 16];
810        b[..8].copy_from_slice(&w.next_instance.to_be_bytes());
811        b[8] = 0xa9;
812        let inst = InstanceId(b);
813        w.agent_instances.insert(name.to_string(), inst);
814        Ok(inst)
815    }
816
817    fn agent_running(&self, name: &str) -> bool {
818        let w = self.w();
819        let id = UnitId::service(&format!("planarian-agent-{name}"));
820        w.units.get(&World::key(&id)).is_some_and(|u| matches!(u.observed, Observed::Active))
821    }
822
823    fn hold(&self, unit: &UnitId, wait: Millis) -> Outcome {
824        if let Some(o) = self.hung("hold", unit, wait) {
825            return o;
826        }
827        let mut w = self.w();
828        if let Some(why) = w.hold_fails.take() {
829            return Outcome::Failed(why);
830        }
831        let now = w.now;
832        // A posture response carries no op (docs/bypasses.md): op 0.
833        w.calls.push(Call { call: format!("hold({unit})"), op: NO_OP, at: now, fenced: false });
834        // The script honours an unexpired release (nix/posture.nix).
835        if !w.hold_released.get(unit).is_some_and(|t| t.is_none_or(|t| t > now)) && !w.hold_declines.contains(unit) {
836            w.held.insert(unit.clone());
837        }
838        Outcome::Succeeded
839    }
840
841    fn lift_hold(&self, unit: &UnitId, wait: Millis) -> Outcome {
842        if let Some(o) = self.hung("lift_hold", unit, wait) {
843            return o;
844        }
845        let mut w = self.w();
846        if let Some(why) = w.hold_fails.take() {
847            return Outcome::Failed(why);
848        }
849        let now = w.now;
850        w.calls.push(Call { call: format!("lift_hold({unit})"), op: NO_OP, at: now, fenced: false });
851        w.held.remove(unit);
852        Outcome::Succeeded
853    }
854
855    fn held(&self) -> Result<BTreeSet<UnitId>, Outcome> {
856        Ok(self.w().held.clone())
857    }
858
859    fn release_hold(&self, unit: &UnitId, until: Option<Timestamp>, wait: Millis) -> Outcome {
860        self.w().hold_released.insert(unit.clone(), until);
861        self.lift_hold(unit, wait)
862    }
863
864    fn end_release(&self, unit: &UnitId) -> Outcome {
865        self.w().hold_released.remove(unit);
866        Outcome::Succeeded
867    }
868
869    fn hold_table_present(&self, wait: Millis) -> Result<bool, Outcome> {
870        let hold_unit = UnitId::service("planarian-hold");
871        if let Some(o) = self.hung("nft list", &hold_unit, wait) {
872            return Err(o);
873        }
874        let w = self.w();
875        if w.hold_table_unreadable {
876            return Err(Outcome::Failed("nft: No such file or directory".into()));
877        }
878        Ok(w.held.is_empty() || !w.hold_table_flushed)
879    }
880
881    fn reapply_hold(&self, unit: &UnitId, wait: Millis) -> Outcome {
882        if let Some(o) = self.hung("reapply_hold", unit, wait) {
883            return o;
884        }
885        let mut w = self.w();
886        let now = w.now;
887        w.calls.push(Call { call: format!("reapply_hold({unit})"), op: NO_OP, at: now, fenced: false });
888        if w.hold_reapply_fails {
889            return Outcome::Failed("planarian-hold: nft: the table did not load".into());
890        }
891        w.held.insert(unit.clone());
892        w.hold_table_flushed = false;
893        Outcome::Succeeded
894    }
895
896    fn restart(&self, id: &UnitId, op: &OpId) -> Outcome {
897        self.run(op, format!("restart({id})"), |w| w.start(id))
898    }
899
900    fn stop(&self, id: &UnitId, op: &OpId) -> Outcome {
901        self.run(op, format!("stop({id})"), |w| {
902            let now = w.now;
903            let sim = w.units.entry(World::key(id)).or_insert_with(|| UnitSim::active(now));
904            sim.observed = Observed::Bound;
905            Outcome::Succeeded
906        })
907    }
908}
909
910impl GenerationBackend for Fake {
911    fn current(&self) -> Result<GenerationId, Outcome> {
912        Ok(self.w().current)
913    }
914
915    fn boot_entries(&self) -> Result<Vec<GenerationId>, Outcome> {
916        Ok(self.w().boot_entries.iter().copied().collect())
917    }
918
919    fn boot_default(&self) -> Result<GenerationId, Outcome> {
920        Ok(self.w().boot_default)
921    }
922
923    fn last_booted(&self) -> Result<GenerationId, Outcome> {
924        Ok(self.w().booted)
925    }
926
927    fn created_by(&self, g: GenerationId) -> Option<(u32, Timestamp)> {
928        self.w().gens.get(&g).map(|x| x.created)
929    }
930
931    fn stage(&self, _owner: &PinnedSource, overrides: &OverrideSet) -> Result<StagedSource, Outcome> {
932        let mut w = self.w();
933        w.stages += 1;
934        let dir = format!("/var/lib/planarian/stage/{}", w.staged.len() + 1);
935        let files = w.owner_files.clone();
936        let base = w.overrides.clone();
937        w.staged.insert(dir.clone(), (overrides.clone(), files.clone()));
938        let candidate_override = World::override_hash_of(overrides);
939        w.override_store.insert(candidate_override, overrides.clone());
940        if std::mem::take(&mut w.edit_on_stage) {
941            w.owner_files.insert("configuration.nix".into(), "{ edited-during-build = true; }".into());
942        }
943        let excluded: Vec<String> = w.untracked.iter().cloned().collect();
944        if let Some(f) = w.untracked_on_stage.take() {
945            w.untracked.insert(f);
946        }
947        Ok(StagedSource {
948            dir,
949            base_source: World::source_hash_of(&files, &base),
950            staged_source: World::source_hash_of(&files, overrides),
951            base_override: World::override_hash_of(&base),
952            candidate_override,
953            excluded,
954        })
955    }
956
957    fn source_hash(&self, _owner: &PinnedSource) -> Result<Hash, Outcome> {
958        let w = self.w();
959        Ok(World::source_hash_of(&w.owner_files, &w.overrides))
960    }
961
962    fn untracked(&self, _owner: &PinnedSource) -> Vec<String> {
963        self.w().untracked.iter().cloned().collect()
964    }
965
966    fn evaluate(&self, src: &StagedSource) -> Result<EvaluatedGeneration, Outcome> {
967        let w = self.w();
968        let (overrides, _) = w.staged.get(&src.dir).ok_or_else(|| Outcome::Failed("no such stage".into()))?;
969        let (mut eval, _) = w.evaluate(overrides);
970        eval.source = src.staged_source;
971        Ok(eval)
972    }
973
974    fn build(&self, eval: &EvaluatedGeneration) -> Result<Built, Outcome> {
975        let mut w = self.w();
976        w.builds += 1;
977        if std::mem::take(&mut w.nospace_on_build) {
978            return Err(Outcome::Failed("error: writing to file: No space left on device".into()));
979        }
980        // R10: the dry activation failed, timed out or was cut short; the
981        // real backend refuses the plan, so this one does too.
982        if let Some(why) = w.dry_activate_fails.take() {
983            return Err(Outcome::Failed(why));
984        }
985        let staged = w
986            .staged
987            .values()
988            .find(|(o, files)| World::source_hash_of(files, o) == eval.source)
989            .cloned()
990            .ok_or_else(|| Outcome::Failed("build: unknown evaluation".into()))?;
991        // Nix builds are deterministic: the same evaluation yields the same path.
992        let existing =
993            w.gens.iter().find(|(_, x)| x.eval.source == eval.source && eval.source != Hash::ZERO).map(|(g, _)| *g);
994        let g = match existing {
995            Some(g) => g,
996            None => {
997                let g = w.add_generation(&staged.0);
998                w.gens.get_mut(&g).expect("added").eval.source = eval.source;
999                g
1000            }
1001        };
1002        let new = w.gens[&g].clone();
1003        let plan = w.dry_plan(g);
1004        let root = format!("/nix/var/nix/gcroots/planarian/builds/{}", eval.source.to_hex());
1005        w.gc_roots.insert(root.clone());
1006        let nar = Some(World::nar_of(&new.path));
1007        Ok(Built { generation: g, path: new.path, root, activation: plan, nar })
1008    }
1009
1010    fn dry_activation(&self, path: &StorePath) -> Result<ActivationPlan, Outcome> {
1011        let mut w = self.w();
1012        if let Some(why) = w.dry_activate_fails.take() {
1013            return Err(Outcome::Failed(why));
1014        }
1015        let g = w.gens.iter().find(|(_, x)| &x.path == path).map(|(g, _)| *g);
1016        let g = g.ok_or_else(|| Outcome::Failed("unknown store path".into()))?;
1017        Ok(w.dry_plan(g))
1018    }
1019
1020    fn override_content(&self, h: &Hash) -> Result<OverrideSet, Outcome> {
1021        self.w().override_store.get(h).cloned().ok_or_else(|| Outcome::Failed("no override content".into()))
1022    }
1023
1024    fn verify_candidate(&self, path: &StorePath, nar: Option<&Hash>) -> Result<bool, Outcome> {
1025        let mut w = self.w();
1026        // C6: the store's hash of the path now; `nar_mismatch` makes it
1027        // differ from the one recorded at build, once.
1028        let now = if std::mem::take(&mut w.nar_mismatch) { Hash::of(b"changed") } else { World::nar_of(path) };
1029        if nar.is_some_and(|n| *n != now) {
1030            return Ok(false);
1031        }
1032        Ok(w.gens.values().any(|g| &g.path == path)
1033            && !w.collected.iter().any(|c| w.gens.get(c).is_some_and(|g| &g.path == path)))
1034    }
1035
1036    fn pending_jobs(&self) -> Result<Vec<JobId>, Outcome> {
1037        Ok(self.w().jobs.keys().cloned().collect())
1038    }
1039
1040    fn jobs_tagged(&self, op: &OpId) -> Result<Option<Vec<JobId>>, Outcome> {
1041        let w = self.w();
1042        if w.jobs_tagged_unknown {
1043            return Ok(None);
1044        }
1045        Ok(Some(w.jobs.iter().filter(|(_, j)| j.op == *op).map(|(k, _)| k.clone()).collect()))
1046    }
1047
1048    fn override_hash(&self) -> Result<Hash, Outcome> {
1049        Ok(World::override_hash_of(&self.w().overrides))
1050    }
1051
1052    fn activate_live(&self, g: GenerationId, op: &OpId) -> Outcome {
1053        self.run(op, format!("activate_live({})", g.0), |w| {
1054            if !w.gens.contains_key(&g) {
1055                return Outcome::Failed(format!("no generation {}", g.0));
1056            }
1057            if w.fail_activate.contains(&g) {
1058                return Outcome::Failed(format!("switch-to-configuration test failed for {}", g.0));
1059            }
1060            if g != w.booted && !w.block_on_activate.is_empty() {
1061                let b = std::mem::take(&mut w.block_on_activate);
1062                w.blocked.extend(b);
1063            }
1064            if let Some((delay, cancellable)) = w.slow_activation {
1065                w.next_job += 1;
1066                let id = JobId(format!("job-{}", w.next_job));
1067                let done_at = w.now.plus(delay);
1068                let job = Job { op: *op, done_at, cancellable, activates: g };
1069                let now = w.now;
1070                w.job_spans.insert(id.clone(), (job.clone(), now, None));
1071                w.jobs.insert(id, job);
1072                return Outcome::Unknown("activation job accepted; no reply".into());
1073            }
1074            w.apply_generation(g);
1075            Outcome::Succeeded
1076        })
1077    }
1078
1079    fn publish_boot(&self, g: GenerationId, op: &OpId) -> Outcome {
1080        self.run(op, format!("publish_boot({})", g.0), |w| {
1081            // Only a generation whose files are not yet on /boot needs new space.
1082            if w.fail_publish && !w.boot_entries.contains(&g) {
1083                // /boot is full from here on, as on the gym VM.
1084                if let Some((free, _)) = w.fs.get_mut("/boot") {
1085                    *free = 0;
1086                }
1087                return Outcome::Failed("No space left on device (/boot)".into());
1088            }
1089            w.boot_entries.insert(g);
1090            w.boot_default = g;
1091            if std::mem::take(&mut w.third_party_on_publish) {
1092                w.overrides.assignments.insert("gym.thirdParty".into(), Value::Str("edited".into()));
1093                w.third_party_override = Some(World::override_hash_of(&w.overrides));
1094            }
1095            Outcome::Succeeded
1096        })
1097    }
1098
1099    fn publish_boot_once(&self, g: GenerationId, keep: GenerationId, op: &OpId) -> Outcome {
1100        self.run(op, format!("publish_boot_once({})", g.0), |w| {
1101            w.boot_entries.insert(g);
1102            w.boot_default = keep;
1103            w.one_shot = Some(g);
1104            Outcome::Succeeded
1105        })
1106    }
1107
1108    fn one_shot_pending(&self) -> Result<Option<GenerationId>, Outcome> {
1109        Ok(self.w().one_shot)
1110    }
1111
1112    fn protect(&self, g: GenerationId, op: &OpId) -> Outcome {
1113        self.run(op, format!("protect({})", g.0), |_| Outcome::Succeeded)
1114    }
1115
1116    fn cancel(&self, job: &JobId, op: &OpId) -> Outcome {
1117        self.run(op, format!("cancel({})", job.0), |w| match w.jobs.get(job) {
1118            Some(j) if j.cancellable => {
1119                w.jobs.remove(job);
1120                let now = w.now;
1121                if let Some(span) = w.job_spans.get_mut(job) {
1122                    span.2 = Some(now);
1123                }
1124                Outcome::Succeeded
1125            }
1126            Some(_) => Outcome::Failed("job cannot be cancelled".into()),
1127            None => Outcome::Succeeded,
1128        })
1129    }
1130
1131    fn swap_override(&self, from: &Hash, to: &Hash, op: &OpId) -> Outcome {
1132        self.run(op, "swap_override".into(), |w| {
1133            let cur = World::override_hash_of(&w.overrides);
1134            if cur == *to {
1135                return Outcome::Succeeded;
1136            }
1137            if cur != *from {
1138                return Outcome::Failed("override file changed by a third party".into());
1139            }
1140            match w.override_store.get(to).cloned() {
1141                Some(set) => {
1142                    w.overrides = set;
1143                    Outcome::Succeeded
1144                }
1145                None => Outcome::Failed("override content unknown".into()),
1146            }
1147        })
1148    }
1149
1150    fn path_of(&self, g: GenerationId) -> Result<StorePath, Outcome> {
1151        self.w().gens.get(&g).map(|x| x.path.clone()).ok_or_else(|| Outcome::Failed(format!("no generation {}", g.0)))
1152    }
1153
1154    fn evaluate_built(&self, path: &StorePath) -> Result<EvaluatedGeneration, Outcome> {
1155        self.w()
1156            .gens
1157            .values()
1158            .find(|g| &g.path == path)
1159            .map(|g| g.eval.clone())
1160            .ok_or_else(|| Outcome::Failed("unknown store path".into()))
1161    }
1162
1163    fn read_overrides(&self) -> Result<(OverrideSet, Hash), Outcome> {
1164        let w = self.w();
1165        Ok((w.overrides.clone(), World::override_hash_of(&w.overrides)))
1166    }
1167
1168    fn remember_override(&self, set: &OverrideSet) -> Result<Hash, Outcome> {
1169        let h = World::override_hash_of(set);
1170        self.w().override_store.insert(h, set.clone());
1171        Ok(h)
1172    }
1173
1174    fn override_for(&self, g: GenerationId) -> Result<Option<Hash>, Outcome> {
1175        Ok(self.w().gen_overrides.get(&g).copied())
1176    }
1177
1178    fn record_override(&self, g: GenerationId, hash: &Hash) -> Result<(), Outcome> {
1179        self.w().gen_overrides.insert(g, *hash);
1180        Ok(())
1181    }
1182
1183    fn release_root(&self, root: &str) -> bool {
1184        self.w().gc_roots.remove(root)
1185    }
1186
1187    fn collect_garbage(&self, protect: &[GenerationId], emergency: bool, op: &OpId) -> Outcome {
1188        self.run(op, "collect_garbage".into(), |w| {
1189            let plan = w.gc_plan(protect, emergency);
1190            let freed = plan.delete.len() as u64 * GEN_BYTES;
1191            w.collected.extend(plan.delete.iter().copied());
1192            for (free, size) in w.fs.values_mut() {
1193                *free = (*free + freed).min(*size);
1194            }
1195            w.space_report = Some(gc::SpaceReport::of(&plan, w.now));
1196            Outcome::Succeeded
1197        })
1198    }
1199
1200    fn gc_plan(&self, protect: &[GenerationId], emergency: bool) -> Result<gc::GcPlan, Outcome> {
1201        Ok(self.w().gc_plan(protect, emergency))
1202    }
1203
1204    fn space_report(&self) -> Option<gc::SpaceReport> {
1205        self.w().space_report.clone()
1206    }
1207}
1208
1209/// The closure size of one fake generation.
1210pub const GEN_BYTES: u64 = 2_000_000_000;
1211
1212impl World {
1213    fn gc_plan(&self, protect: &[GenerationId], emergency: bool) -> gc::GcPlan {
1214        let on_disk: Vec<GenerationId> = self.gens.keys().copied().filter(|g| !self.collected.contains(g)).collect();
1215        let mut keep: Vec<GenerationId> = protect.to_vec();
1216        keep.extend([self.current, self.boot_default]);
1217        // Built candidates still rooted under gcroots/planarian/builds survive
1218        // until they are in the profile (published), whose link roots them
1219        // from then on; a collection drops the build root of such a one.
1220        keep.extend(on_disk.iter().copied().filter(|g| {
1221            let generation = &self.gens[g];
1222            !self.boot_entries.contains(g)
1223                && self.gc_roots.iter().any(|r| r.ends_with(&generation.eval.source.to_hex()))
1224        }));
1225        let fs_size = self.fs.get("/nix").map(|(_, size)| *size).unwrap_or(u64::MAX);
1226        let b = self.gc_budget;
1227        let count = if emergency { b.keep_emergency } else { b.keep_generations };
1228        gc::plan(&on_disk, &b.protected(&on_disk, &keep), count, b.max_bytes(fs_size), |gs| gs.len() as u64 * GEN_BYTES)
1229    }
1230}
1231
1232impl InventoryBackend for Fake {
1233    fn devices(&self) -> Result<Vec<DeviceInfo>, Outcome> {
1234        // A driver that can't drive the device leaves it effectively unbound.
1235        let w = self.w();
1236        Ok(w.devices.iter().map(|(id, d)| DeviceInfo { id: id.clone(), driver: d.clone() }).collect())
1237    }
1238
1239    fn resources(&self) -> Result<Resources, Outcome> {
1240        Ok(Resources { mem_total_mb: 16_000, mem_available_mb: 12_000, fs_free_bytes: BTreeMap::new() })
1241    }
1242
1243    fn bound_driver(&self, device: &UnitId) -> Result<Option<DriverRef>, Outcome> {
1244        Ok(self.w().devices.get(device).cloned().flatten())
1245    }
1246
1247    fn bind(&self, device: &UnitId, driver: &DriverRef, op: &OpId) -> Outcome {
1248        self.run(op, format!("bind({device}, {})", driver.name), |w| {
1249            w.devices.insert(device.clone(), Some(driver.clone()));
1250            Outcome::Succeeded
1251        })
1252    }
1253
1254    fn unbind(&self, device: &UnitId, op: &OpId) -> Outcome {
1255        self.run(op, format!("unbind({device})"), |w| {
1256            w.devices.insert(device.clone(), None);
1257            Outcome::Succeeded
1258        })
1259    }
1260}
1261
1262impl ProbeBackend for Fake {
1263    fn run_batch(&self, jobs: &[(UnitId, ProbeDef)]) -> Vec<ProbeResult> {
1264        let mut w = self.w();
1265        w.probe_batches += 1;
1266        let slow = w.probe_batch_ms;
1267        w.now = Timestamp(w.now.0 + slow);
1268        jobs.iter()
1269            .map(|(unit, p)| {
1270                if let ProbeKind::FreeSpace { path, min_bytes, min_permille } = &p.kind {
1271                    return match w.fs.get(path) {
1272                        Some((free, size)) if *free >= *min_bytes && free * 1000 >= size * *min_permille as u64 => {
1273                            ProbeResult::Pass
1274                        }
1275                        Some((free, _)) => ProbeResult::Fail(format!("{free} bytes free on {path}")),
1276                        None => ProbeResult::Error(format!("no filesystem at {path}")),
1277                    };
1278                }
1279                if w.blocked.contains(unit) {
1280                    return ProbeResult::Fail("connection refused".into());
1281                }
1282                // A sound card is listed while snd-hda0 is driven.
1283                if p.id.0 == "pipewire-has-a-card" {
1284                    return match w.devices.get(&UnitId::device("snd-hda0")).cloned().flatten() {
1285                        Some(d) if !w.bad_drivers.contains(&d.name) => ProbeResult::Pass,
1286                        _ => ProbeResult::Fail("no sound card".into()),
1287                    };
1288                }
1289                // A device's own check: driven by a driver that can drive it.
1290                if unit.kind == UnitKind::Device && matches!(p.kind, ProbeKind::Exec { .. }) {
1291                    return match w.devices.get(unit).cloned().flatten() {
1292                        Some(d) if !w.bad_drivers.contains(&d.name) => ProbeResult::Pass,
1293                        Some(d) => ProbeResult::Fail(format!("{} cannot drive it", d.name)),
1294                        None => ProbeResult::Fail("no driver".into()),
1295                    };
1296                }
1297                // A Wi-Fi interface is healthy while associated with a network in range.
1298                if w.base_units.get(unit).and_then(|d| d.runtime_repair.as_deref()) == Some("wifi.network") {
1299                    return match w.runtime.get("wifi.network") {
1300                        Some(Value::Str(n)) if w.wifi_in_range.contains(n) => ProbeResult::Pass,
1301                        _ => ProbeResult::Fail("not associated".into()),
1302                    };
1303                }
1304                let unreachable = |u: &UnitId| {
1305                    u.name.strip_prefix("external:").is_some_and(|x| w.external_unreachable.contains(x))
1306                        || w.base_units.get(u).is_some_and(|d| {
1307                            d.edges.iter().any(|e| {
1308                                e.to.name.strip_prefix("external:").is_some_and(|x| w.external_unreachable.contains(x))
1309                            })
1310                        })
1311                };
1312                if unreachable(unit) {
1313                    return ProbeResult::Timeout;
1314                }
1315                if let Some(ext) = unit.name.strip_prefix("external:") {
1316                    return if w.external_down.contains(ext) {
1317                        ProbeResult::Fail(format!("{ext}: connection refused"))
1318                    } else {
1319                        ProbeResult::Pass
1320                    };
1321                }
1322                // A client of a down external dependency sees its upstream fail.
1323                if w.base_units.get(unit).is_some_and(|d| {
1324                    d.edges
1325                        .iter()
1326                        .any(|e| e.to.name.strip_prefix("external:").is_some_and(|x| w.external_down.contains(x)))
1327                }) {
1328                    return ProbeResult::Fail("upstream unavailable (502)".into());
1329                }
1330                match w.units.get(&World::key(unit)) {
1331                    None => ProbeResult::Fail("not running".into()),
1332                    Some(sim) if sim.observed != Observed::Active => ProbeResult::Fail("connection refused".into()),
1333                    Some(sim) => match (&sim.health, &p.kind) {
1334                        (Health::Ok, _) => ProbeResult::Pass,
1335                        (Health::Http503, ProbeKind::Http { .. }) => ProbeResult::Fail("HTTP 503".into()),
1336                        (Health::Http503, _) => ProbeResult::Pass,
1337                        // A slow answer to the request; connecting is still quick.
1338                        (Health::Delay(d), ProbeKind::Http { .. }) if d.0 > p.timeout.0 => ProbeResult::Timeout,
1339                        (Health::Delay(_), _) => ProbeResult::Pass,
1340                        (Health::Stall(d), _) if d.0 > p.timeout.0 => ProbeResult::Timeout,
1341                        (Health::Stall(_), _) => ProbeResult::Pass,
1342                    },
1343                }
1344            })
1345            .collect()
1346    }
1347}
1348
1349impl SessionBackend for Fake {
1350    fn sessions(&self) -> Result<Vec<SessionInfo>, Outcome> {
1351        Ok(self.w().sessions.clone())
1352    }
1353
1354    fn compositor_running(&self, s: &SessionInfo) -> Option<bool> {
1355        self.w().compositors.get(&s.id).copied()
1356    }
1357
1358    fn foreground(&self) -> Vec<String> {
1359        self.w().foreground.iter().cloned().collect()
1360    }
1361
1362    fn power(&self) -> Option<PowerSource> {
1363        self.w().power
1364    }
1365}
1366
1367impl RuntimeOptionBackend for Fake {
1368    fn get(&self, path: &str) -> Option<Value> {
1369        self.w().runtime.get(path).cloned()
1370    }
1371
1372    fn set(&self, path: &str, value: &Value, op: &OpId) -> Outcome {
1373        self.run(op, format!("set({path})"), |w| {
1374            w.runtime.insert(path.into(), value.clone());
1375            Outcome::Succeeded
1376        })
1377    }
1378
1379    fn alternatives(&self, path: &str) -> Vec<Value> {
1380        let w = self.w();
1381        match path {
1382            "wifi.network" => {
1383                let current = w.runtime.get(path);
1384                w.wifi_known
1385                    .iter()
1386                    .filter(|n| w.wifi_in_range.contains(*n))
1387                    .map(|n| Value::Str(n.clone()))
1388                    .filter(|v| Some(v) != current)
1389                    .collect()
1390            }
1391            _ => vec![],
1392        }
1393    }
1394}
1395
1396impl ProcessBackend for Fake {
1397    fn kill_group(&self, pid: u32) -> bool {
1398        let mut w = self.w();
1399        if w.unkillable.contains(&pid) {
1400            return false;
1401        }
1402        w.alive.remove(&pid);
1403        true
1404    }
1405
1406    fn alive(&self, pid: u32) -> bool {
1407        self.w().alive.contains(&pid)
1408    }
1409}
1410
1411impl Clock for Fake {
1412    /// The world's `now` is elapsed time (boot time); the wall clock is it
1413    /// plus `wall_skew`, which only `set_wall_clock` moves (§12h).
1414    fn now(&self) -> Timestamp {
1415        let w = self.w();
1416        Timestamp(w.now.0.saturating_add_signed(w.wall_skew))
1417    }
1418
1419    fn boot_now(&self) -> Timestamp {
1420        self.w().now
1421    }
1422
1423    fn sleep(&self, d: Millis) {
1424        self.advance(d);
1425    }
1426}
1427
1428impl Backends for Fake {
1429    type Units = Fake;
1430    type Gens = Fake;
1431    type Inv = Fake;
1432    type Probes = Fake;
1433    type Sessions = Fake;
1434    type Runtime = Fake;
1435    type Procs = Fake;
1436    type Clock = Fake;
1437
1438    fn units(&self) -> &Fake {
1439        self
1440    }
1441    fn generations(&self) -> &Fake {
1442        self
1443    }
1444    fn inventory(&self) -> &Fake {
1445        self
1446    }
1447    fn probes(&self) -> &Fake {
1448        self
1449    }
1450    fn sessions(&self) -> &Fake {
1451        self
1452    }
1453    fn runtime(&self) -> &Fake {
1454        self
1455    }
1456    fn processes(&self) -> &Fake {
1457        self
1458    }
1459    fn clock(&self) -> &Fake {
1460        self
1461    }
1462}
1463
1464/// A fake machine built from the gym fixtures (catalog `[[fixture]]`), on its
1465/// first generation, healthy, with nobody logged in.
1466pub fn gym_world(store: MemStore) -> Fake {
1467    let mut w = World { now: Timestamp(1_700_000_000_000), power: Some(PowerSource::Ac), ..Default::default() };
1468    w.base_units = fixtures::gym_units();
1469    w.base_user_units = fixtures::gym_user_units();
1470    w.base_options = [
1471        ("services.gym-web.listenAddress".to_string(), Value::Str("127.0.0.1".into())),
1472        ("services.openssh.enable".to_string(), Value::Bool(true)),
1473        ("planarian.reference.fontScale".to_string(), Value::Str("1".into())),
1474        ("gym.vulnlib".to_string(), Value::Str("1.0".into())),
1475        ("gym.kernelParam".to_string(), Value::Str("".into())),
1476        ("services.gym-bad-payload.enable".to_string(), Value::Bool(false)),
1477        ("gym.modules".to_string(), Value::Str("old".into())),
1478    ]
1479    .into();
1480    // gym-vulnlib 1.0 carries knownVulnerabilities; 1.1 fixes them (§12e).
1481    w.base_packages = [("gym-vulnlib".to_string(), "1.0".to_string())].into();
1482    w.base_insecure = ["gym-vulnlib".to_string()].into();
1483    w.base_inputs = [("gym-modules".to_string(), ("old".to_string(), "/nix/store/aaa-gym-modules".to_string()))].into();
1484    w.rules = vec![
1485        // A flake input update whose new module declares the payload.
1486        Rule {
1487            option: "gym.modules".into(),
1488            value: Value::Str("new".into()),
1489            effect: Effect::Declare {
1490                service: "gym-bad-payload".into(),
1491                file: "/nix/store/bbb-gym-modules/module.nix".into(),
1492            },
1493        },
1494        // The bad payload: a declared service, off by default, defined in the
1495        // gym's own module (§15 malicious declaration).
1496        Rule {
1497            option: "services.gym-bad-payload.enable".into(),
1498            value: Value::Bool(true),
1499            effect: Effect::Declare { service: "gym-bad-payload".into(), file: "/etc/nixos/configuration.nix".into() },
1500        },
1501        // A kernel parameter: takes effect only at boot (§12f).
1502        Rule {
1503            option: "gym.kernelParam".into(),
1504            value: Value::Str("virtio_gpu.modeset=0".into()),
1505            effect: Effect::NeedsReboot,
1506        },
1507        // The catalog's English entries (§17a): a change to Planarian's own
1508        // unit, and one that removes sshd's canonical probe.
1509        Rule { option: "gym.touchCore".into(), value: Value::Str("on".into()), effect: Effect::TouchCore },
1510        Rule {
1511            option: "gym.dropProbe".into(),
1512            value: Value::Str("sshd-tcp".into()),
1513            effect: Effect::DropProbe(UnitId::service("sshd"), "sshd-tcp".into()),
1514        },
1515        Rule {
1516            option: "gym.vulnlib".into(),
1517            value: Value::Str("1.1".into()),
1518            effect: Effect::Package { name: "gym-vulnlib".into(), version: "1.1".into(), insecure: false },
1519        },
1520        // The same fix in a release that also needs a reboot (the boot-only
1521        // path, I25).
1522        Rule {
1523            option: "gym.vulnlib".into(),
1524            value: Value::Str("1.2".into()),
1525            effect: Effect::Package { name: "gym-vulnlib".into(), version: "1.2".into(), insecure: false },
1526        },
1527        Rule { option: "gym.vulnlib".into(), value: Value::Str("1.2".into()), effect: Effect::NeedsReboot },
1528        // A library a later release makes insecure: going back to the older
1529        // generation is not a fix (§12e).
1530        Rule {
1531            option: "gym.newlib".into(),
1532            value: Value::Str("1.0".into()),
1533            effect: Effect::Package { name: "gym-newlib".into(), version: "1.0".into(), insecure: false },
1534        },
1535        Rule {
1536            option: "gym.newlib".into(),
1537            value: Value::Str("2.0".into()),
1538            effect: Effect::Package { name: "gym-newlib".into(), version: "2.0".into(), insecure: true },
1539        },
1540        Rule {
1541            option: "services.gym-web.listenAddress".into(),
1542            value: Value::Str("192.0.2.10".into()),
1543            effect: Effect::Break(UnitId::service("gym-web")),
1544        },
1545        Rule {
1546            option: "services.openssh.enable".into(),
1547            value: Value::Bool(false),
1548            effect: Effect::Remove(UnitId::service("sshd")),
1549        },
1550    ];
1551    w.core_units = [
1552        ("planariad.service".to_string(), Hash::of(b"planariad")),
1553        ("planarian-recovery.service".to_string(), Hash::of(b"recovery")),
1554    ]
1555    .into();
1556    w.owner_files = [
1557        ("flake.nix".to_string(), "{ outputs = …; }".to_string()),
1558        ("configuration.nix".to_string(), "{ … }".to_string()),
1559    ]
1560    .into();
1561    w.devices = [
1562        (UnitId::device("snd-hda0"), Some(DriverRef { name: "snd_hda_intel".into(), module: None })),
1563        (UnitId::device("net-gym0"), Some(DriverRef { name: "virtio_net".into(), module: None })),
1564        (UnitId::device("gpu0"), Some(DriverRef { name: "virtio_gpu".into(), module: None })),
1565        (UnitId::device("wlan-hwsim"), Some(DriverRef { name: "mac80211_hwsim".into(), module: None })),
1566    ]
1567    .into();
1568    w.wifi_known = vec!["gym-ap-a".into(), "gym-ap-b".into()];
1569    w.wifi_in_range = ["gym-ap-a".to_string(), "gym-ap-b".to_string()].into();
1570    w.runtime.insert("wifi.network".into(), Value::Str("gym-ap-a".into()));
1571    w.bad_drivers.insert("vfio-pci".into());
1572    w.fs = [
1573        ("/".to_string(), (40_000_000_000, 100_000_000_000)),
1574        ("/nix".to_string(), (40_000_000_000, 100_000_000_000)),
1575        ("/boot".to_string(), (400_000_000, 1_000_000_000)),
1576        ("/var/lib/planarian".to_string(), (40_000_000_000, 100_000_000_000)),
1577    ]
1578    .into();
1579    let g = w.add_generation(&OverrideSet::default());
1580    w.boot_entries.insert(g);
1581    w.boot_default = g;
1582    w.booted = g;
1583    w.override_store.insert(World::override_hash_of(&OverrideSet::default()), OverrideSet::default());
1584    let now = w.now;
1585    for id in w.base_units.keys() {
1586        if id.kind == UnitKind::Service && !id.name.starts_with("external:") {
1587            w.units.insert(id.clone(), UnitSim::active(now));
1588        }
1589    }
1590    if let Some(sim) = w.units.get_mut(&UnitId::service("gym-restartful")) {
1591        sim.systemd_restart = Some(Millis::secs(5));
1592    }
1593    w.apply_generation(g);
1594    Fake::new(w, store)
1595}
1596
1597pub mod fixtures;