Skip to main content

planarian_backend_linux/
nixos.rs

1//! `GenerationBackend` over Nix and NixOS's `switch-to-configuration`.
2//!
3//! Generation ids are Planarian's own, kept in `generations.json`. They map to
4//! system store paths, because a candidate needs an id before it is in the
5//! system profile (docs/deviations.md).
6//!
7//! Staging (§9 step 1) copies the owner's Nix-visible files (git-tracked files
8//! with their working-tree contents, or the whole directory if it isn't a git
9//! repository) into `stage/`. `base_source` is the NAR hash of that copy with
10//! the live override; `staged_source`, with the candidate override.
11//! Evaluation and build read the staged copy as a `path:` flake in pure-eval
12//! mode, never the owner's live tree.
13//!
14//! Live activation runs `switch-to-configuration test` in a transient unit
15//! named after the OpId (`planarian-op-<txn>-<epoch>-<seq>.service`), so
16//! `jobs_tagged` can find the work an op started even if the reply was lost.
17
18use crate::Paths;
19use crate::admitted;
20use crate::cmd::{Output, run};
21use planarian_changes::{parse as parse_overrides, render};
22use planarian_schema::*;
23use serde::{Deserialize, Serialize};
24use std::collections::{BTreeMap, BTreeSet};
25use std::path::{Path, PathBuf};
26use std::sync::{Arc, Mutex};
27use std::time::Duration;
28
29const NIX: Duration = Duration::from_secs(30 * 60);
30const QUICK: Duration = Duration::from_secs(30);
31/// Twice §12f's 24 h bound on parking in `Built`: a remembered override or a
32/// build root is never removed while a transaction that made it can still
33/// use it.
34const PARKING_GRACE: Duration = Duration::from_secs(48 * 3600);
35pub const ACTIVATION: Duration = Duration::from_secs(16 * 60);
36
37#[derive(Clone, Debug, Default, Serialize, Deserialize)]
38pub struct Registry {
39    pub next: u64,
40    pub paths: BTreeMap<u64, String>,
41    pub overrides: BTreeMap<u64, Hash>,
42}
43
44impl Registry {
45    pub fn id_for(&mut self, path: &str) -> GenerationId {
46        if let Some((id, _)) = self.paths.iter().find(|(_, p)| *p == path) {
47            return GenerationId(*id);
48        }
49        self.next = self.next.max(self.paths.keys().max().copied().unwrap_or(0)) + 1;
50        self.paths.insert(self.next, path.to_string());
51        GenerationId(self.next)
52    }
53}
54
55/// The JSON a generation exports at `/etc/planarian/export.json` and through
56/// `config.services.planarian.export` (see nix/module.nix).
57#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
58#[serde(rename_all = "camelCase", default)]
59pub struct Export {
60    pub units: Vec<UnitDef>,
61    pub user_units: Vec<UnitDef>,
62    pub core_units: BTreeMap<String, String>,
63    pub devices: BTreeMap<String, String>,
64    /// pname → version of the system packages (§12e).
65    pub packages: BTreeMap<String, String>,
66    /// pnames whose `meta.knownVulnerabilities` is not empty.
67    pub insecure: Vec<String>,
68    /// Every system service → the files defining it (§15 provenance).
69    #[serde(default)]
70    pub declarations: BTreeMap<String, Vec<String>>,
71    /// Flake inputs: name → { rev, path }.
72    #[serde(default)]
73    pub inputs: BTreeMap<String, ExportInput>,
74    /// Every undeclared system service → its shipped-table sensitivity (§11).
75    #[serde(default)]
76    pub services: BTreeMap<String, Sensitivities>,
77}
78
79#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
80pub struct ExportInput {
81    #[serde(default)]
82    pub rev: String,
83    #[serde(default)]
84    pub path: String,
85}
86
87impl Export {
88    pub fn into_eval(self, source: Hash) -> EvaluatedGeneration {
89        EvaluatedGeneration {
90            source,
91            options: BTreeMap::new(),
92            units: self.units.into_iter().map(|d| (d.id.clone(), d)).collect(),
93            user_units: self.user_units,
94            activation: ActivationPlan::default(),
95            locked: BTreeSet::new(),
96            core_units: self
97                .core_units
98                .into_iter()
99                .map(|(k, v)| (k, Hash::from_hex(&v).unwrap_or(Hash::of(v.as_bytes()))))
100                .collect(),
101            packages: self.packages,
102            insecure: self.insecure.into_iter().collect(),
103            declarations: self.declarations,
104            inputs: self.inputs.into_iter().map(|(k, v)| (k, (v.rev, v.path))).collect(),
105            services: self.services,
106        }
107    }
108}
109
110/// Why a dry activation gives no usable plan, if it does not (R10).
111pub fn dry_activate_failure(o: &Output) -> Option<String> {
112    if o.timed_out {
113        Some("dry activation timed out: its plan is unknown".into())
114    } else if o.truncated {
115        Some("dry activation output was cut short: its plan is incomplete".into())
116    } else if !o.ok() {
117        Some(format!("dry activation failed ({:?}): {}", o.status, o.tail()))
118    } else {
119        None
120    }
121}
122
123/// Parse `switch-to-configuration dry-activate` output.
124pub fn parse_dry_activate(text: &str) -> ActivationPlan {
125    let mut plan = ActivationPlan::default();
126    for line in text.lines() {
127        let line = line.trim();
128        let (set, list) = if let Some(l) = line.strip_prefix("would stop the following units: ") {
129            (&mut plan.stop, l)
130        } else if let Some(l) = line.strip_prefix("would restart the following units: ") {
131            (&mut plan.restart, l)
132        } else if let Some(l) = line.strip_prefix("would start the following units: ") {
133            (&mut plan.start, l)
134        } else if let Some(l) = line.strip_prefix("would reload the following units: ") {
135            (&mut plan.reload, l)
136        } else {
137            continue;
138        };
139        for u in list.split(',').map(str::trim).filter(|u| !u.is_empty()) {
140            if let Some(n) = u.strip_suffix(".service") {
141                set.insert(UnitId::service(n));
142            } else if let Some(n) = u.strip_suffix(".target") {
143                set.insert(UnitId::target(n));
144            }
145        }
146    }
147    plan
148}
149
150/// The Nix function given to `nix eval --apply`: the export, plus the current
151/// value and highest priority of each option path the overrides touch.
152pub fn apply_expr(paths: &[String]) -> String {
153    let list = |p: &str| {
154        let segs: Vec<String> = p.split('.').map(|s| format!("{s:?}")).collect();
155        format!("[ {} ]", segs.join(" "))
156    };
157    let walk = |root: &str, p: &str| {
158        format!(
159            "(builtins.foldl' (a: k: if a != null && builtins.isAttrs a && a ? ${{k}} then a.${{k}} else null) {root} {})",
160            list(p)
161        )
162    };
163    let mut values = String::new();
164    let mut prios = String::new();
165    for p in paths {
166        values.push_str(&format!(" {p:?} = {};", walk("c.config", p)));
167        prios.push_str(&format!(
168            " {p:?} = (let o = {}; in if o != null && o ? highestPrio then o.highestPrio else 100);",
169            walk("c.options", p)
170        ));
171    }
172    format!("c: {{ export = c.config.services.planarian.export; values = {{{values} }}; prios = {{{prios} }}; }}")
173}
174
175/// The total size of the union of the closures of `paths`.
176fn closure_bytes(paths: &[&str]) -> Result<u64, Outcome> {
177    if paths.is_empty() {
178        return Ok(0);
179    }
180    let mut args = vec!["path-info", "--recursive", "--json"];
181    args.extend(paths);
182    let o = nix(&args, QUICK);
183    if !o.ok() {
184        return Err(err(&o));
185    }
186    closure_bytes_of(&o.stdout).ok_or_else(|| Outcome::Failed("nix path-info: unexpected output".into()))
187}
188
189/// The `narHash` of `path` in `nix path-info --json` output, in either of
190/// its shapes.
191pub fn nar_hash_of(json: &str, path: &str) -> Option<String> {
192    let v: serde_json::Value = serde_json::from_str(json).ok()?;
193    let entry = match &v {
194        serde_json::Value::Object(m) => m.get(path)?,
195        serde_json::Value::Array(a) => a.iter().find(|x| x["path"].as_str() == Some(path))?,
196        _ => return None,
197    };
198    entry["narHash"].as_str().map(String::from)
199}
200
201/// A `sha256-<base64>` (SRI) hash as the 32 bytes it names; None for any
202/// other algorithm or a malformed value.
203pub fn sri_sha256(sri: &str) -> Option<Hash> {
204    let b64 = sri.strip_prefix("sha256-")?;
205    let val = |c: u8| -> Option<u32> {
206        Some(match c {
207            b'A'..=b'Z' => c - b'A',
208            b'a'..=b'z' => c - b'a' + 26,
209            b'0'..=b'9' => c - b'0' + 52,
210            b'+' => 62,
211            b'/' => 63,
212            _ => return None,
213        } as u32)
214    };
215    let mut out = Vec::with_capacity(32);
216    let (mut acc, mut bits) = (0u32, 0u32);
217    for c in b64.bytes().take_while(|c| *c != b'=') {
218        acc = (acc << 6) | val(c)?;
219        bits += 6;
220        if bits >= 8 {
221            bits -= 8;
222            out.push((acc >> bits) as u8);
223            acc &= (1 << bits) - 1;
224        }
225    }
226    out.try_into().ok().map(Hash)
227}
228
229fn store_nar_hash(path: &str) -> Option<String> {
230    let o = nix(&["path-info", "--json", path], QUICK);
231    if o.ok() { nar_hash_of(&o.stdout, path) } else { None }
232}
233
234/// Sum `narSize` over the paths `nix path-info --json` lists, in either of
235/// its shapes: an object keyed by path, or an array of objects with `path`.
236pub fn closure_bytes_of(json: &str) -> Option<u64> {
237    let v: serde_json::Value = serde_json::from_str(json).ok()?;
238    let sizes: Vec<u64> = match &v {
239        serde_json::Value::Object(m) => m.values().map(|x| x["narSize"].as_u64().unwrap_or(0)).collect(),
240        serde_json::Value::Array(a) => a.iter().map(|x| x["narSize"].as_u64().unwrap_or(0)).collect(),
241        _ => return None,
242    };
243    Some(sizes.iter().sum())
244}
245
246fn value_from_json(v: &serde_json::Value) -> Option<Value> {
247    Some(match v {
248        serde_json::Value::Bool(b) => Value::Bool(*b),
249        serde_json::Value::Number(n) => match n.as_i64() {
250            Some(i) => Value::Int(i),
251            None => Value::Str(n.to_string()),
252        },
253        serde_json::Value::String(s) => Value::Str(s.clone()),
254        serde_json::Value::Array(a) => Value::List(a.iter().filter_map(value_from_json).collect()),
255        _ => return None,
256    })
257}
258
259#[derive(Clone, Debug)]
260pub struct Nixos {
261    paths: Paths,
262    lock: Arc<Mutex<()>>,
263    budget: gc::GcBudget,
264}
265
266fn sha(text: &str) -> Hash {
267    Hash::of(text.as_bytes())
268}
269
270fn err(o: &Output) -> Outcome {
271    if o.timed_out { Outcome::Unknown("timed out".into()) } else { Outcome::Failed(o.tail()) }
272}
273
274fn nix(args: &[&str], timeout: Duration) -> Output {
275    let mut argv = vec!["nix", "--extra-experimental-features", "nix-command flakes"];
276    argv.extend_from_slice(args);
277    run(&argv, timeout)
278}
279
280/// NAR hash of a directory.
281fn nar_hash(dir: &Path) -> Result<Hash, Outcome> {
282    let o = run(&["nix-hash", "--type", "sha256", "--base16", &dir.to_string_lossy()], NIX);
283    if !o.ok() {
284        return Err(err(&o));
285    }
286    Hash::from_hex(o.stdout.trim())
287        .ok_or_else(|| Outcome::Failed(format!("unexpected nix-hash output {:?}", o.stdout.trim())))
288}
289
290fn copy_file(src: &Path, dst: &Path) -> std::io::Result<()> {
291    if let Some(p) = dst.parent() {
292        std::fs::create_dir_all(p)?;
293    }
294    let meta = std::fs::symlink_metadata(src)?;
295    if meta.file_type().is_symlink() {
296        std::os::unix::fs::symlink(std::fs::read_link(src)?, dst)
297    } else {
298        std::fs::copy(src, dst).map(|_| ())
299    }
300}
301
302fn walk(root: &Path, dir: &Path, out: &mut Vec<PathBuf>) -> std::io::Result<()> {
303    for e in std::fs::read_dir(dir)? {
304        let e = e?;
305        let p = e.path();
306        if p.file_name().is_some_and(|n| n == ".git") {
307            continue;
308        }
309        if e.file_type()?.is_dir() {
310            walk(root, &p, out)?;
311        } else {
312            out.push(p.strip_prefix(root).expect("under root").to_path_buf());
313        }
314    }
315    Ok(())
316}
317
318impl Nixos {
319    /// `slow` (gym hooks only): the activation unit first sleeps this long,
320    /// and if `uncancellable`, ignores being stopped; it is the unit that is
321    /// slow, so it outlives the daemon like a real long activation.
322    fn activate_live_inner(&self, g: GenerationId, op: &OpId, slow: Option<(std::time::Duration, bool)>) -> Outcome {
323        if !admitted(&self.paths.txn_dir(), op) {
324            return Outcome::fenced();
325        }
326        let path = match self.path_of(g) {
327            Ok(p) => p,
328            Err(o) => return o,
329        };
330        let unit = format!("--unit={}", Self::unit_name(op));
331        let switch = format!("{}/bin/switch-to-configuration", path.0);
332        let mut cmd: Vec<String> = [
333            "systemd-run",
334            &unit,
335            "--wait",
336            "--collect",
337            "--quiet",
338            "--service-type=oneshot",
339            "--property=TimeoutStartSec=15min",
340        ]
341        .map(String::from)
342        .to_vec();
343        match slow {
344            None => cmd.extend([switch, "test".into()]),
345            Some((d, uncancellable)) => {
346                if uncancellable {
347                    cmd.extend(
348                        [
349                            "--property=KillSignal=SIGWINCH",
350                            "--property=SendSIGKILL=no",
351                            "--property=TimeoutStopSec=infinity",
352                        ]
353                        .map(String::from),
354                    );
355                }
356                // A transient unit's PATH is systemd's default, empty on NixOS.
357                cmd.extend([
358                    "/bin/sh".into(),
359                    "-c".into(),
360                    format!("PATH=/run/current-system/sw/bin; sleep {}; exec {switch} test", d.as_secs()),
361                ]);
362            }
363        }
364        let args: Vec<&str> = cmd.iter().map(String::as_str).collect();
365        let o = run(&args, ACTIVATION);
366        if o.ok() { Outcome::Succeeded } else { err(&o) }
367    }
368
369    pub fn new(paths: Paths) -> Nixos {
370        Nixos { paths, lock: Arc::new(Mutex::new(())), budget: gc::GcBudget::default() }
371    }
372
373    /// The §12b bounds (`services.planarian.gc`).
374    pub fn with_budget(mut self, budget: gc::GcBudget) -> Nixos {
375        self.budget = budget;
376        self
377    }
378
379    /// The system profile's generations: (profile number, store path).
380    fn profile_generations(&self) -> Vec<(u64, String)> {
381        let dir = self.paths.profile.parent().expect("profile dir");
382        let prefix = format!("{}-", self.paths.profile.file_name().expect("name").to_string_lossy());
383        let mut gens: Vec<(u64, String)> = std::fs::read_dir(dir)
384            .map(|rd| {
385                rd.flatten()
386                    .filter_map(|e| {
387                        let n = e.file_name().to_string_lossy().into_owned();
388                        let num = n.strip_prefix(&prefix)?.strip_suffix("-link")?.parse().ok()?;
389                        Some((num, std::fs::canonicalize(e.path()).ok()?.to_string_lossy().into_owned()))
390                    })
391                    .collect()
392            })
393            .unwrap_or_default();
394        gens.sort();
395        gens
396    }
397
398    /// Roots that no longer hold anything Planarian needs, removed before a
399    /// collection so it can free what the profile no longer keeps (§9 step 5,
400    /// §12b): a build root whose candidate is a profile generation (the
401    /// profile link roots it from commit on, and a collected one must go),
402    /// and a `gen-<n>` root for a generation no longer protected.
403    fn prune_roots(&self, profile: &[(u64, String)], protect: &[GenerationId]) {
404        let in_profile: BTreeSet<&str> = profile.iter().map(|(_, p)| p.as_str()).collect();
405        let entries = |d: &Path| std::fs::read_dir(d).map(|rd| rd.flatten().collect::<Vec<_>>()).unwrap_or_default();
406        for e in entries(&self.paths.gcroots.join("builds")) {
407            let target = std::fs::read_link(e.path()).ok().map(|t| t.to_string_lossy().into_owned());
408            if target.is_some_and(|t| in_profile.contains(t.as_str())) {
409                let _ = std::fs::remove_file(e.path());
410            }
411        }
412        for e in entries(&self.paths.gcroots) {
413            let name = e.file_name().to_string_lossy().into_owned();
414            let Some(n) = name.strip_prefix("gen-").and_then(|n| n.parse::<u64>().ok()) else { continue };
415            if !protect.contains(&GenerationId(n)) {
416                let _ = std::fs::remove_file(e.path());
417            }
418        }
419    }
420
421    /// Build roots older than `PARKING_GRACE` (by the link's own time): no
422    /// transaction can still be waiting on them, so they are a crashed or
423    /// abandoned build's (C4).
424    fn age_out_roots_at(&self, now: std::time::SystemTime) -> Vec<String> {
425        let Ok(rd) = std::fs::read_dir(self.paths.gcroots.join("builds")) else { return vec![] };
426        let mut gone = vec![];
427        for e in rd.flatten() {
428            let old = std::fs::symlink_metadata(e.path())
429                .and_then(|m| m.modified())
430                .ok()
431                .and_then(|m| now.duration_since(m).ok())
432                .is_some_and(|age| age >= PARKING_GRACE);
433            if old && std::fs::remove_file(e.path()).is_ok() {
434                gone.push(e.file_name().to_string_lossy().into_owned());
435            }
436        }
437        gone.sort();
438        gone
439    }
440
441    /// Plan over profile numbers. Protected generations are the profile
442    /// entries whose store path is a protected generation's.
443    fn plan_profile(
444        &self,
445        protect: &[GenerationId],
446        emergency: bool,
447    ) -> Result<(gc::GcPlan, Vec<(u64, String)>), Outcome> {
448        let protected_paths: BTreeSet<String> =
449            protect.iter().filter_map(|g| self.path_of(*g).ok()).map(|p| p.0).collect();
450        let gens = self.profile_generations();
451        let ids: Vec<GenerationId> = gens.iter().map(|(n, _)| GenerationId(*n)).collect();
452        let keep: Vec<GenerationId> =
453            gens.iter().filter(|(_, p)| protected_paths.contains(p)).map(|(n, _)| GenerationId(*n)).collect();
454        let fs_bytes = match rustix::fs::statvfs("/nix/store") {
455            Ok(v) => v.f_blocks.saturating_mul(v.f_frsize),
456            Err(e) => return Err(Outcome::Failed(format!("statvfs /nix/store: {e}"))),
457        };
458        let count = if emergency { self.budget.keep_emergency } else { self.budget.keep_generations };
459        let mut failure = None;
460        let keep = self.budget.protected(&ids, &keep);
461        let plan = gc::plan(&ids, &keep, count, self.budget.max_bytes(fs_bytes), |set| {
462            let paths: Vec<&str> =
463                gens.iter().filter(|(n, _)| set.contains(&GenerationId(*n))).map(|(_, p)| p.as_str()).collect();
464            closure_bytes(&paths).unwrap_or_else(|o| {
465                failure = Some(o);
466                0
467            })
468        });
469        match failure {
470            Some(o) => Err(o),
471            None => Ok((plan, gens)),
472        }
473    }
474
475    /// The store path the boot menu was last installed for.
476    fn boot_menu_path(&self) -> PathBuf {
477        self.paths.state.join("boot-menu")
478    }
479
480    /// The boot menu is the one for `path`: as recorded after the last
481    /// successful install, or, before any (a machine installed before this
482    /// record existed), when `path` is the system that booted.
483    /// The number of the profile generation the profile link points at
484    /// (`system-<n>-link`).
485    fn profile_generation(&self) -> Option<u64> {
486        let l = std::fs::read_link(&self.paths.profile).ok()?;
487        let name = l.file_name()?.to_string_lossy().into_owned();
488        name.strip_suffix("-link")?.rsplit('-').next()?.parse().ok()
489    }
490
491    fn boot_menu_for(&self, path: &str) -> bool {
492        match std::fs::read_to_string(self.boot_menu_path()) {
493            Ok(p) => p.trim() == path,
494            Err(_) => {
495                std::fs::canonicalize("/run/booted-system").ok().map(|p| p.to_string_lossy().into_owned())
496                    == Some(path.to_string())
497            }
498        }
499    }
500
501    fn report_path(&self) -> PathBuf {
502        self.paths.state.join("gc-report.json")
503    }
504
505    fn registry_path(&self) -> PathBuf {
506        self.paths.state.join("generations.json")
507    }
508
509    fn with_registry<R>(&self, f: impl FnOnce(&mut Registry) -> R) -> Result<R, Outcome> {
510        let _g = self.lock.lock().unwrap_or_else(|e| e.into_inner());
511        let p = self.registry_path();
512        let mut reg: Registry = match std::fs::read(&p) {
513            Ok(b) => serde_json::from_slice(&b).map_err(|e| Outcome::Failed(format!("generations.json: {e}")))?,
514            Err(_) => Registry::default(),
515        };
516        let before = serde_json::to_vec(&reg).expect("serializes");
517        let r = f(&mut reg);
518        let after = serde_json::to_vec(&reg).expect("serializes");
519        if after != before {
520            let tmp = p.with_extension("json.tmp");
521            std::fs::create_dir_all(&self.paths.state).map_err(|e| Outcome::Failed(e.to_string()))?;
522            std::fs::write(&tmp, &after)
523                .and_then(|_| std::fs::rename(&tmp, &p))
524                .map_err(|e| Outcome::Failed(e.to_string()))?;
525        }
526        Ok(r)
527    }
528
529    fn id_of_link(&self, link: &Path) -> Result<GenerationId, Outcome> {
530        let target = std::fs::canonicalize(link).map_err(|e| Outcome::Failed(format!("{}: {e}", link.display())))?;
531        let t = target.to_string_lossy().into_owned();
532        self.with_registry(|r| r.id_for(&t))
533    }
534
535    fn override_file(&self, root: &Path) -> PathBuf {
536        root.join("planarian/overrides.nix")
537    }
538
539    fn store_dir(&self) -> PathBuf {
540        self.paths.state.join("overrides")
541    }
542
543    /// The owner's Nix-visible files, and the untracked files left out.
544    fn visible_files(&self, root: &Path) -> Result<(Vec<PathBuf>, Vec<String>), Outcome> {
545        let r = root.to_string_lossy().into_owned();
546        if root.join(".git").exists() {
547            let safe = format!("safe.directory={r}");
548            let tracked = run(&["git", "-c", &safe, "-C", &r, "ls-files", "-z", "--cached"], QUICK);
549            if !tracked.ok() {
550                return Err(err(&tracked));
551            }
552            let untracked =
553                run(&["git", "-c", &safe, "-C", &r, "ls-files", "-z", "--others", "--exclude-standard"], QUICK);
554            let files = tracked
555                .stdout
556                .split('\0')
557                .filter(|s| !s.is_empty())
558                .map(PathBuf::from)
559                .filter(|p| root.join(p).exists())
560                .collect();
561            let skipped = untracked.stdout.split('\0').filter(|s| !s.is_empty()).map(String::from).collect();
562            Ok((files, skipped))
563        } else {
564            let mut out = vec![];
565            walk(root, root, &mut out).map_err(|e| Outcome::Failed(e.to_string()))?;
566            Ok((out, vec![]))
567        }
568    }
569
570    /// Copy the owner's visible files into a fresh directory.
571    fn copy_owner(&self, root: &Path) -> Result<(PathBuf, Vec<String>), Outcome> {
572        let (files, skipped) = self.visible_files(root)?;
573        let rel = Path::new("planarian/overrides.nix");
574        if !files.iter().any(|f| f == rel) {
575            return Err(Outcome::Failed(
576                "planarian/overrides.nix is not part of your configuration (for a git flake: `git add planarian/overrides.nix`)".into(),
577            ));
578        }
579        let stage = self.paths.state.join("stage");
580        let tmp = stage.join(format!("tmp-{}-{}", std::process::id(), Timestamp_now()));
581        for f in &files {
582            copy_file(&root.join(f), &tmp.join(f)).map_err(|e| Outcome::Failed(format!("{}: {e}", f.display())))?;
583        }
584        Ok((tmp, skipped))
585    }
586
587    fn flake_ref(&self, dir: &Path) -> String {
588        format!("path:{}#{}", dir.display(), self.paths.flake_attr)
589    }
590
591    fn staged_dir(&self, source: &Hash) -> PathBuf {
592        self.paths.state.join("stage").join(source.to_hex())
593    }
594
595    fn unit_name(op: &OpId) -> String {
596        format!("planarian-op-{}-{}-{}", op.txn.0, op.epoch, op.seq)
597    }
598
599    /// §9 step 4 (I23, R11): an exclusive `flock` on a directory, held until
600    /// the returned handle drops. The directory itself is locked, so nothing
601    /// is added to the owner's tree. Cooperating writers take the same lock;
602    /// one that does not is outside I23 (`docs/bypasses.md`, X-12).
603    fn lock_dir(dir: &Path) -> std::io::Result<std::fs::File> {
604        let d = std::fs::File::open(dir)?;
605        rustix::fs::flock(&d, rustix::fs::FlockOperation::LockExclusive)?;
606        Ok(d)
607    }
608
609    /// Promote, restore and collect (the system profile and boot menu) are
610    /// serialized across the daemon and the watchdog (§28, C5).
611    fn lock_profile(&self) -> Result<std::fs::File, Outcome> {
612        std::fs::create_dir_all(&self.paths.state).map_err(|e| Outcome::Failed(e.to_string()))?;
613        let f = std::fs::OpenOptions::new()
614            .create(true)
615            .truncate(false)
616            .write(true)
617            .open(self.paths.state.join("profile.lock"))
618            .map_err(|e| Outcome::Failed(e.to_string()))?;
619        rustix::fs::flock(&f, rustix::fs::FlockOperation::LockExclusive).map_err(|e| Outcome::Failed(e.to_string()))?;
620        Ok(f)
621    }
622
623    /// The content-addressed override store keeps what a generation still in
624    /// the profile or protected references, the live file's content, and
625    /// anything remembered within `PARKING_GRACE` (a transaction still in
626    /// flight has remembered its base and candidate but recorded neither).
627    /// Everything else goes (§28, C5). Returns the pruned hashes.
628    fn prune_override_store(&self, keep_gens: &[GenerationId], now: std::time::SystemTime) -> Vec<String> {
629        let mut keep: BTreeSet<String> = BTreeSet::new();
630        if let Ok(reg) = self.with_registry(|r| r.overrides.clone()) {
631            for g in keep_gens {
632                if let Some(h) = reg.get(&g.0) {
633                    keep.insert(h.to_hex());
634                }
635            }
636        }
637        if let Ok(t) = std::fs::read_to_string(self.override_file(&self.paths.owner)) {
638            keep.insert(sha(&t).to_hex());
639        }
640        let Ok(entries) = std::fs::read_dir(self.store_dir()) else { return vec![] };
641        let mut gone = vec![];
642        for e in entries.flatten() {
643            let name = e.file_name().to_string_lossy().into_owned();
644            let Some(hex) = name.strip_suffix(".nix") else { continue };
645            let recent = e
646                .metadata()
647                .and_then(|m| m.modified())
648                .ok()
649                .and_then(|m| now.duration_since(m).ok())
650                .is_none_or(|age| age < PARKING_GRACE);
651            if !keep.contains(hex) && !recent && std::fs::remove_file(e.path()).is_ok() {
652                gone.push(hex.to_string());
653            }
654        }
655        gone.sort();
656        gone
657    }
658
659    fn write_atomically(target: &Path, text: &str) -> std::io::Result<()> {
660        use std::io::Write;
661        let dir = target.parent().expect("has a parent");
662        let meta = std::fs::metadata(target).ok();
663        let tmp = dir.join(".overrides.nix.planarian-tmp");
664        {
665            let mut f = std::fs::File::create(&tmp)?;
666            f.write_all(text.as_bytes())?;
667            f.sync_all()?;
668        }
669        if let Some(m) = meta {
670            use std::os::unix::fs::MetadataExt;
671            let _ = rustix::fs::chown(
672                &tmp,
673                Some(rustix::fs::Uid::from_raw(m.uid())),
674                Some(rustix::fs::Gid::from_raw(m.gid())),
675            );
676        }
677        std::fs::rename(&tmp, target)?;
678        std::fs::File::open(dir)?.sync_all()
679    }
680}
681
682#[allow(non_snake_case)]
683fn Timestamp_now() -> u128 {
684    std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0)
685}
686
687impl GenerationBackend for Nixos {
688    fn current(&self) -> Result<GenerationId, Outcome> {
689        self.id_of_link(Path::new("/run/current-system"))
690    }
691
692    fn boot_entries(&self) -> Result<Vec<GenerationId>, Outcome> {
693        let dir = self.paths.profile.parent().expect("profile has a directory");
694        let prefix = format!("{}-", self.paths.profile.file_name().expect("profile name").to_string_lossy());
695        let mut out = vec![];
696        for e in std::fs::read_dir(dir).map_err(|e| Outcome::Failed(e.to_string()))?.flatten() {
697            let n = e.file_name().to_string_lossy().into_owned();
698            if n.starts_with(&prefix) && n.ends_with("-link") {
699                out.push(self.id_of_link(&e.path())?);
700            }
701        }
702        out.sort();
703        out.dedup();
704        Ok(out)
705    }
706
707    fn boot_default(&self) -> Result<GenerationId, Outcome> {
708        self.id_of_link(&self.paths.profile)
709    }
710
711    fn last_booted(&self) -> Result<GenerationId, Outcome> {
712        self.id_of_link(Path::new("/run/booted-system"))
713    }
714
715    /// The profile link's owner and modification time (`nixos-rebuild` as
716    /// root, or Planarian, which also runs as root: the journal tells those
717    /// apart; this answers for rebuilds outside Planarian).
718    fn created_by(&self, g: GenerationId) -> Option<(u32, Timestamp)> {
719        use std::os::unix::fs::MetadataExt;
720        let dir = self.paths.profile.parent()?;
721        // Planarian's generation ids are not profile numbers: find the
722        // profile link that holds this generation's system path (the newest,
723        // if several do).
724        let path = self.path_of(g).ok()?;
725        let number = self.profile_generations().into_iter().filter(|(_, p)| *p == path.0).map(|(n, _)| n).max()?;
726        let name = format!("{}-{}-link", self.paths.profile.file_name()?.to_string_lossy(), number);
727        let m = std::fs::symlink_metadata(dir.join(name)).ok()?;
728        Some((m.uid(), Timestamp(u64::try_from(m.mtime()).ok()? * 1000)))
729    }
730
731    fn stage(&self, owner: &PinnedSource, overrides: &OverrideSet) -> Result<StagedSource, Outcome> {
732        let root = PathBuf::from(&owner.root);
733        let (tmp, excluded) = self.copy_owner(&root)?;
734        let live = std::fs::read_to_string(self.override_file(&tmp)).map_err(|e| Outcome::Failed(e.to_string()))?;
735        let base_source = nar_hash(&tmp)?;
736        let candidate = render(overrides);
737        std::fs::write(self.override_file(&tmp), &candidate).map_err(|e| Outcome::Failed(e.to_string()))?;
738        let staged_source = nar_hash(&tmp)?;
739        #[cfg(feature = "gym-hooks")]
740        eprintln!("gym: staged {}: base {} candidate {}", owner.root, base_source.to_hex(), staged_source.to_hex());
741        let dir = self.staged_dir(&staged_source);
742        if dir.exists() {
743            let _ = std::fs::remove_dir_all(&tmp);
744        } else {
745            std::fs::rename(&tmp, &dir).map_err(|e| Outcome::Failed(e.to_string()))?;
746        }
747        self.remember_override(overrides)?;
748        Ok(StagedSource {
749            dir: dir.to_string_lossy().into_owned(),
750            base_source,
751            staged_source,
752            base_override: sha(&live),
753            candidate_override: sha(&candidate),
754            excluded,
755        })
756    }
757
758    fn source_hash(&self, owner: &PinnedSource) -> Result<Hash, Outcome> {
759        let (tmp, _) = self.copy_owner(Path::new(&owner.root))?;
760        let h = nar_hash(&tmp);
761        let _ = std::fs::remove_dir_all(&tmp);
762        #[cfg(feature = "gym-hooks")]
763        eprintln!("gym: source hash of {} now {:?}", owner.root, h.as_ref().map(|h| h.to_hex()));
764        h
765    }
766
767    fn untracked(&self, owner: &PinnedSource) -> Vec<String> {
768        self.visible_files(Path::new(&owner.root)).map(|(_, skipped)| skipped).unwrap_or_default()
769    }
770
771    fn evaluate(&self, src: &StagedSource) -> Result<EvaluatedGeneration, Outcome> {
772        let dir = PathBuf::from(&src.dir);
773        let text = std::fs::read_to_string(self.override_file(&dir)).map_err(|e| Outcome::Failed(e.to_string()))?;
774        let set = parse_overrides(&text).unwrap_or_default();
775        let paths: Vec<String> = set.assignments.keys().cloned().collect();
776        let o = nix(
777            &["eval", "--json", "--option", "pure-eval", "true", &self.flake_ref(&dir), "--apply", &apply_expr(&paths)],
778            NIX,
779        );
780        if !o.ok() {
781            return Err(err(&o));
782        }
783        #[derive(Deserialize)]
784        struct Out {
785            export: Export,
786            values: BTreeMap<String, serde_json::Value>,
787            prios: BTreeMap<String, i64>,
788        }
789        let out: Out =
790            serde_json::from_str(&o.stdout).map_err(|e| Outcome::Failed(format!("evaluation output: {e}")))?;
791        let mut eval = out.export.into_eval(src.staged_source);
792        eval.options = out.values.iter().filter_map(|(k, v)| value_from_json(v).map(|v| (k.clone(), v))).collect();
793        // mkOverride 90 loses to anything with a lower number (mkForce is 50).
794        eval.locked = out.prios.into_iter().filter(|(_, p)| *p < 90).map(|(k, _)| k).collect();
795        Ok(eval)
796    }
797
798    fn build(&self, eval: &EvaluatedGeneration) -> Result<Built, Outcome> {
799        #[cfg(feature = "gym-hooks")]
800        crate::gym::pause_point("build");
801        let dir = self.staged_dir(&eval.source);
802        let root = self.paths.gcroots.join("builds").join(eval.source.to_hex());
803        std::fs::create_dir_all(root.parent().expect("parent")).map_err(|e| Outcome::Failed(e.to_string()))?;
804        let attr = format!("{}.config.system.build.toplevel", self.flake_ref(&dir));
805        let o = nix(&["build", "--out-link", &root.to_string_lossy(), "--print-out-paths", &attr], NIX);
806        if !o.ok() {
807            return Err(err(&o));
808        }
809        let printed = o.stdout.lines().last().unwrap_or_default().trim().to_string();
810        // Resolve symlinks, so a generation has one name whichever way it was
811        // reached (`/run/current-system` is resolved the same way).
812        let path = std::fs::canonicalize(&printed).map(|p| p.to_string_lossy().into_owned()).unwrap_or(printed);
813        let generation = self.with_registry(|r| r.id_for(&path))?;
814        // C6: the transaction carries this (`candidate_nar`) to verification.
815        let nar = store_nar_hash(&path).and_then(|h| sri_sha256(&h));
816        // The candidate is not usable without its plan (rule 21, R10).
817        let activation = match self.dry_activation(&StorePath(path.clone())) {
818            Ok(a) => a,
819            Err(o) => {
820                let _ = std::fs::remove_file(&root);
821                return Err(o);
822            }
823        };
824        Ok(Built { generation, path: StorePath(path), root: root.to_string_lossy().into_owned(), activation, nar })
825    }
826
827    fn dry_activation(&self, path: &StorePath) -> Result<ActivationPlan, Outcome> {
828        let path = &path.0;
829        let dry = run(&[&format!("{path}/bin/switch-to-configuration"), "dry-activate"], Duration::from_secs(120));
830        // The plan is the risk evidence (§9 step 3): a dry activation that
831        // failed, timed out or was cut short yields none, never a smaller one
832        // (rule 21, R10).
833        if let Some(why) = dry_activate_failure(&dry) {
834            return Err(Outcome::Failed(why));
835        }
836        let mut activation = parse_dry_activate(&format!("{}\n{}", dry.stdout, dry.stderr));
837        let differs = |f: &str| {
838            std::fs::read_link(format!("{path}/{f}")).ok()
839                != std::fs::read_link(format!("/run/current-system/{f}")).ok()
840        };
841        activation.needs_reboot_to_confirm = differs("kernel") || differs("initrd") || {
842            std::fs::read_to_string(format!("{path}/kernel-params")).ok()
843                != std::fs::read_to_string("/run/current-system/kernel-params").ok()
844        };
845        Ok(activation)
846    }
847
848    /// C6: the store path's contents still hash to what the store recorded
849    /// (`nix-store --verify-path`), and that is the NAR hash the transaction
850    /// recorded when it built the candidate. Existence alone is not enough.
851    fn verify_candidate(&self, path: &StorePath, nar: Option<&Hash>) -> Result<bool, Outcome> {
852        if !Path::new(&path.0).join("bin/switch-to-configuration").exists() {
853            return Ok(false);
854        }
855        if !run(&["nix-store", "--verify-path", &path.0], NIX).ok() {
856            return Ok(false);
857        }
858        Ok(match nar {
859            Some(want) => store_nar_hash(&path.0).and_then(|h| sri_sha256(&h)).is_some_and(|got| got == *want),
860            // Not built here (a generation from outside Planarian): the
861            // store's own record is the one --verify-path checked.
862            None => true,
863        })
864    }
865
866    fn pending_jobs(&self) -> Result<Vec<JobId>, Outcome> {
867        let o = run(
868            &[
869                "systemctl",
870                "list-units",
871                "--plain",
872                "--no-legend",
873                "--state=activating,active,deactivating,reloading",
874                "planarian-op-*",
875            ],
876            QUICK,
877        );
878        if !o.ok() {
879            return Err(err(&o));
880        }
881        Ok(o.stdout.lines().filter_map(|l| l.split_whitespace().next()).map(|n| JobId(n.into())).collect())
882    }
883
884    fn jobs_tagged(&self, op: &OpId) -> Result<Option<Vec<JobId>>, Outcome> {
885        #[cfg(feature = "gym-hooks")]
886        if crate::gym::hook("jobs_tagged", None).result.as_deref() == Some("None") {
887            return Ok(None);
888        }
889        let name = format!("{}.service", Self::unit_name(op));
890        let o = run(&["systemctl", "show", "--property=ActiveState,LoadState", &name], QUICK);
891        if !o.ok() {
892            return Ok(None);
893        }
894        let props = crate::systemd::parse_show(&o.stdout);
895        let active = matches!(
896            props.get("ActiveState").map(String::as_str),
897            Some("active" | "activating" | "deactivating" | "reloading")
898        );
899        Ok(Some(if active { vec![JobId(name)] } else { vec![] }))
900    }
901
902    fn override_hash(&self) -> Result<Hash, Outcome> {
903        std::fs::read_to_string(self.override_file(&self.paths.owner))
904            .map(|t| sha(&t))
905            .map_err(|e| Outcome::Failed(e.to_string()))
906    }
907
908    fn activate_live(&self, g: GenerationId, op: &OpId) -> Outcome {
909        // Gym hooks: the delay goes into the activation unit, not the daemon.
910        #[cfg(feature = "gym-hooks")]
911        let (h, slow) = {
912            let mut h = crate::gym::hook("activate_live", Some(g));
913            let slow = h.delay.take().map(|d| (d, h.uncancellable));
914            (h, slow)
915        };
916        #[cfg(feature = "gym-hooks")]
917        return crate::gym::apply(h, || self.activate_live_inner(g, op, slow));
918        #[cfg(not(feature = "gym-hooks"))]
919        self.activate_live_inner(g, op, None)
920    }
921
922    fn publish_boot(&self, g: GenerationId, op: &OpId) -> Outcome {
923        #[cfg(feature = "gym-hooks")]
924        crate::gym::pause_point("publish_boot");
925        if !admitted(&self.paths.txn_dir(), op) {
926            return Outcome::fenced();
927        }
928        let _lock = match self.lock_profile() {
929            Ok(l) => l,
930            Err(o) => return o,
931        };
932        let path = match self.path_of(g) {
933            Ok(p) => p,
934            Err(o) => return o,
935        };
936        let profile = self.paths.profile.to_string_lossy().into_owned();
937        let already = std::fs::canonicalize(&self.paths.profile).ok().map(|p| p.to_string_lossy().into_owned())
938            == Some(path.0.clone());
939        // Idempotent: the profile points at g and the boot menu was installed
940        // for it (a revert after a publish that failed and was undone, or a
941        // retry after a crash past the install).
942        if already && self.boot_menu_for(&path.0) {
943            return Outcome::Succeeded;
944        }
945        if !already {
946            let o = run(&["nix-env", "-p", &profile, "--set", &path.0], QUICK);
947            if !o.ok() {
948                return err(&o);
949            }
950        }
951        let o = run(&[&format!("{}/bin/switch-to-configuration", path.0), "boot"], Duration::from_secs(300));
952        if o.ok() {
953            let _ = std::fs::write(self.boot_menu_path(), &path.0);
954            return Outcome::Succeeded;
955        }
956        // All or nothing: the boot loader failed (a full /boot), so the menu on
957        // disk is still the previous one. Point the profile back at what that
958        // menu boots and drop the new profile generation, so the boot default
959        // is not left naming a generation the menu does not have.
960        if !already {
961            let added = std::fs::read_link(&self.paths.profile)
962                .ok()
963                .and_then(|l| l.file_name().map(|n| n.to_string_lossy().into_owned()))
964                .and_then(|n| n.rsplit('-').nth(1).map(String::from));
965            let _ = run(&["nix-env", "-p", &profile, "--rollback"], QUICK);
966            if let Some(n) = added {
967                let _ = run(&["nix-env", "-p", &profile, "--delete-generations", &n], QUICK);
968            }
969        }
970        err(&o)
971    }
972
973    /// The boot-only path with systemd-boot (§12f): `g` gets its own profile
974    /// generation and boot entry, `keep` is made the newest profile
975    /// generation again (so the menu's default stays `keep`), and the entry
976    /// for `g` is set to boot once (`bootctl set-oneshot`). All or nothing:
977    /// on any failure the profile is put back on `keep`'s boot menu.
978    fn publish_boot_once(&self, g: GenerationId, keep: GenerationId, op: &OpId) -> Outcome {
979        if !admitted(&self.paths.txn_dir(), op) {
980            return Outcome::fenced();
981        }
982        let _lock = match self.lock_profile() {
983            Ok(l) => l,
984            Err(o) => return o,
985        };
986        let (Ok(path), Ok(kept)) = (self.path_of(g), self.path_of(keep)) else {
987            return Outcome::Failed("unknown generation".into());
988        };
989        let profile = self.paths.profile.to_string_lossy().into_owned();
990        let set = |p: &str| run(&["nix-env", "-p", &profile, "--set", p], QUICK);
991        let o = set(&path.0);
992        if !o.ok() {
993            return err(&o);
994        }
995        let Some(n) = self.profile_generation() else {
996            let _ = set(&kept.0);
997            return Outcome::Failed("cannot read the profile generation".into());
998        };
999        let o = set(&kept.0);
1000        if !o.ok() {
1001            return err(&o);
1002        }
1003        let o = run(&[&format!("{}/bin/switch-to-configuration", kept.0), "boot"], Duration::from_secs(300));
1004        if !o.ok() {
1005            return err(&o);
1006        }
1007        let _ = std::fs::write(self.boot_menu_path(), &kept.0);
1008        let o = run(&["bootctl", "set-oneshot", &format!("nixos-generation-{n}.conf")], QUICK);
1009        if o.ok() { Outcome::Succeeded } else { err(&o) }
1010    }
1011
1012    /// The pending one-shot entry, from systemd-boot's `LoaderEntryOneShot`
1013    /// EFI variable: `nixos-generation-<n>.conf` → the profile generation's path.
1014    fn one_shot_pending(&self) -> Result<Option<GenerationId>, Outcome> {
1015        let efivars = Path::new("/sys/firmware/efi/efivars");
1016        if !efivars.is_dir() {
1017            return Err(Outcome::Unknown("no EFI variables".into()));
1018        }
1019        let var = efivars.join("LoaderEntryOneShot-4a67b082-0a4c-41cf-b6c7-440b29bb8c4f");
1020        let Ok(bytes) = std::fs::read(&var) else { return Ok(None) };
1021        // 4 bytes of attributes, then UTF-16LE.
1022        let units: Vec<u16> =
1023            bytes.get(4..).unwrap_or(&[]).chunks_exact(2).map(|c| u16::from_le_bytes([c[0], c[1]])).collect();
1024        let name = String::from_utf16_lossy(&units).trim_end_matches('\0').to_string();
1025        let Some(n) = name.strip_prefix("nixos-generation-").and_then(|r| r.strip_suffix(".conf")) else {
1026            return Ok(None);
1027        };
1028        let link = self.paths.profile.with_file_name(format!("system-{n}-link"));
1029        self.id_of_link(&link).map(Some)
1030    }
1031
1032    fn protect(&self, g: GenerationId, op: &OpId) -> Outcome {
1033        if !admitted(&self.paths.txn_dir(), op) {
1034            return Outcome::fenced();
1035        }
1036        let path = match self.path_of(g) {
1037            Ok(p) => p,
1038            Err(o) => return o,
1039        };
1040        let link = self.paths.gcroots.join(format!("gen-{}", g.0));
1041        let _ = std::fs::create_dir_all(&self.paths.gcroots);
1042        let _ = std::fs::remove_file(&link);
1043        match std::os::unix::fs::symlink(&path.0, &link) {
1044            Ok(()) => Outcome::Succeeded,
1045            Err(e) => Outcome::Failed(e.to_string()),
1046        }
1047    }
1048
1049    fn cancel(&self, job: &JobId, op: &OpId) -> Outcome {
1050        if !admitted(&self.paths.txn_dir(), op) {
1051            return Outcome::fenced();
1052        }
1053        let o = run(&["systemctl", "stop", &job.0], Duration::from_secs(90));
1054        if o.ok() { Outcome::Succeeded } else { err(&o) }
1055    }
1056
1057    fn swap_override(&self, from: &Hash, to: &Hash, op: &OpId) -> Outcome {
1058        #[cfg(feature = "gym-hooks")]
1059        crate::gym::pause_point("swap_override");
1060        if !admitted(&self.paths.txn_dir(), op) {
1061            return Outcome::fenced();
1062        }
1063        let live = self.override_file(&self.paths.owner);
1064        // Compare and rename under one lock: a cooperating writer cannot land
1065        // between the two (I23).
1066        let _lock = match Self::lock_dir(live.parent().expect("has a parent")) {
1067            Ok(l) => l,
1068            Err(e) => return Outcome::Failed(format!("cannot lock planarian/: {e}")),
1069        };
1070        let current = match std::fs::read_to_string(&live) {
1071            Ok(t) => sha(&t),
1072            Err(e) => return Outcome::Failed(e.to_string()),
1073        };
1074        if current == *to {
1075            return Outcome::Succeeded;
1076        }
1077        if current != *from {
1078            return Outcome::Failed("planarian/overrides.nix was changed by someone else".into());
1079        }
1080        let text = match std::fs::read_to_string(self.store_dir().join(format!("{}.nix", to.to_hex()))) {
1081            Ok(t) if sha(&t) == *to => t,
1082            _ => return Outcome::Failed("override content for this transaction is missing".into()),
1083        };
1084        match Self::write_atomically(&live, &text) {
1085            Ok(()) => Outcome::Succeeded,
1086            Err(e) => Outcome::Failed(e.to_string()),
1087        }
1088    }
1089
1090    fn path_of(&self, g: GenerationId) -> Result<StorePath, Outcome> {
1091        self.with_registry(|r| r.paths.get(&g.0).cloned())?
1092            .map(StorePath)
1093            .ok_or_else(|| Outcome::Failed(format!("unknown generation {}", g.0)))
1094    }
1095
1096    fn evaluate_built(&self, path: &StorePath) -> Result<EvaluatedGeneration, Outcome> {
1097        let p = Path::new(&path.0).join("etc/planarian/export.json");
1098        let text = std::fs::read_to_string(&p).map_err(|e| Outcome::Failed(format!("{}: {e}", p.display())))?;
1099        let export: Export =
1100            serde_json::from_str(&text).map_err(|e| Outcome::Failed(format!("{}: {e}", p.display())))?;
1101        Ok(export.into_eval(Hash::ZERO))
1102    }
1103
1104    fn read_overrides(&self) -> Result<(OverrideSet, Hash), Outcome> {
1105        let text = std::fs::read_to_string(self.override_file(&self.paths.owner))
1106            .map_err(|e| Outcome::Failed(e.to_string()))?;
1107        let set = parse_overrides(&text).ok_or_else(|| {
1108            Outcome::Failed(
1109                "planarian/overrides.nix was edited by hand; restore it from `git` or remove the edits".into(),
1110            )
1111        })?;
1112        Ok((set, sha(&text)))
1113    }
1114
1115    fn remember_override(&self, set: &OverrideSet) -> Result<Hash, Outcome> {
1116        let text = render(set);
1117        let h = sha(&text);
1118        let dir = self.store_dir();
1119        std::fs::create_dir_all(&dir).map_err(|e| Outcome::Failed(e.to_string()))?;
1120        std::fs::write(dir.join(format!("{}.nix", h.to_hex())), text).map_err(|e| Outcome::Failed(e.to_string()))?;
1121        Ok(h)
1122    }
1123
1124    fn override_for(&self, g: GenerationId) -> Result<Option<Hash>, Outcome> {
1125        self.with_registry(|r| r.overrides.get(&g.0).copied())
1126    }
1127
1128    fn override_content(&self, h: &Hash) -> Result<OverrideSet, Outcome> {
1129        let p = self.store_dir().join(format!("{}.nix", h.to_hex()));
1130        let text = std::fs::read_to_string(&p).map_err(|e| Outcome::Failed(format!("{}: {e}", p.display())))?;
1131        if sha(&text) != *h {
1132            return Err(Outcome::Failed(format!("{}: content does not match its hash", p.display())));
1133        }
1134        parse_overrides(&text).ok_or_else(|| Outcome::Failed(format!("{}: not a rendered override set", p.display())))
1135    }
1136
1137    fn record_override(&self, g: GenerationId, hash: &Hash) -> Result<(), Outcome> {
1138        self.with_registry(|r| {
1139            r.overrides.insert(g.0, *hash);
1140        })
1141    }
1142
1143    fn gc_plan(&self, protect: &[GenerationId], emergency: bool) -> Result<gc::GcPlan, Outcome> {
1144        self.plan_profile(protect, emergency).map(|(p, _)| p)
1145    }
1146
1147    fn space_report(&self) -> Option<gc::SpaceReport> {
1148        std::fs::read(self.report_path()).ok().and_then(|b| serde_json::from_slice(&b).ok())
1149    }
1150
1151    fn release_root(&self, root: &str) -> bool {
1152        Path::new(root).starts_with(&self.paths.gcroots) && std::fs::remove_file(root).is_ok()
1153    }
1154
1155    fn age_out_build_roots(&self) -> Vec<String> {
1156        self.age_out_roots_at(std::time::SystemTime::now())
1157    }
1158
1159    fn collect_garbage(&self, protect: &[GenerationId], emergency: bool, op: &OpId) -> Outcome {
1160        if !admitted(&self.paths.txn_dir(), op) {
1161            return Outcome::fenced();
1162        }
1163        let _lock = match self.lock_profile() {
1164            Ok(l) => l,
1165            Err(o) => return o,
1166        };
1167        let (plan, gens) = match self.plan_profile(protect, emergency) {
1168            Ok(p) => p,
1169            Err(o) => return o,
1170        };
1171        // Protected generations stay in the profile, so they stay in the boot menu (I1, I3).
1172        let delete: Vec<String> = plan.delete.iter().map(|g| g.0.to_string()).collect();
1173        if !delete.is_empty() {
1174            let profile = self.paths.profile.to_string_lossy().into_owned();
1175            let mut argv = vec!["nix-env", "-p", &profile, "--delete-generations"];
1176            argv.extend(delete.iter().map(String::as_str));
1177            let o = run(&argv, QUICK);
1178            if !o.ok() {
1179                return err(&o);
1180            }
1181        }
1182        let now = std::time::SystemTime::now()
1183            .duration_since(std::time::UNIX_EPOCH)
1184            .map(|d| d.as_millis() as u64)
1185            .unwrap_or(0);
1186        let report = gc::SpaceReport::of(&plan, Timestamp(now));
1187        if let Ok(json) = serde_json::to_vec_pretty(&report) {
1188            let _ = std::fs::write(self.report_path(), json);
1189        }
1190        for g in protect {
1191            if let Ok(p) = self.path_of(*g) {
1192                let link = self.paths.gcroots.join(format!("gen-{}", g.0));
1193                let _ = std::fs::create_dir_all(&self.paths.gcroots);
1194                if std::fs::read_link(&link).ok().map(|l| l.to_string_lossy().into_owned()) != Some(p.0.clone()) {
1195                    let _ = std::fs::remove_file(&link);
1196                    let _ = std::os::unix::fs::symlink(&p.0, &link);
1197                }
1198            }
1199        }
1200        self.prune_roots(&gens, protect);
1201        self.age_out_roots_at(std::time::SystemTime::now());
1202        let kept: Vec<GenerationId> = self
1203            .profile_generations()
1204            .iter()
1205            .filter_map(|(_, p)| {
1206                self.with_registry(|r| r.paths.iter().find(|(_, q)| *q == p).map(|(id, _)| GenerationId(*id)))
1207                    .ok()
1208                    .flatten()
1209            })
1210            .chain(protect.iter().copied())
1211            .collect();
1212        self.prune_override_store(&kept, std::time::SystemTime::now());
1213        let o = run(&["nix-store", "--gc"], NIX);
1214        if !o.ok() {
1215            return err(&o);
1216        }
1217        if !delete.is_empty() {
1218            let _ = run(
1219                &[&format!("{}/bin/switch-to-configuration", self.paths.profile.display()), "boot"],
1220                Duration::from_secs(300),
1221            );
1222        }
1223        Outcome::Succeeded
1224    }
1225}
1226
1227#[cfg(test)]
1228mod tests {
1229    use super::*;
1230
1231    /// §12b: a committed candidate's build root and a no-longer-protected
1232    /// generation's root go before a collection; a pending candidate's and a
1233    /// protected generation's stay.
1234    #[test]
1235    fn stale_roots_are_pruned_before_a_collection() {
1236        let d = tempfile::tempdir().unwrap();
1237        let paths = Paths { gcroots: d.path().join("gcroots"), ..Default::default() };
1238        let builds = paths.gcroots.join("builds");
1239        std::fs::create_dir_all(&builds).unwrap();
1240        std::os::unix::fs::symlink("/nix/store/aaa-committed", builds.join("a")).unwrap();
1241        std::os::unix::fs::symlink("/nix/store/bbb-pending", builds.join("b")).unwrap();
1242        std::os::unix::fs::symlink("/nix/store/aaa-committed", paths.gcroots.join("gen-5")).unwrap();
1243        std::os::unix::fs::symlink("/nix/store/ccc", paths.gcroots.join("gen-6")).unwrap();
1244        let n = Nixos::new(paths.clone());
1245        n.prune_roots(&[(1, "/nix/store/aaa-committed".into())], &[GenerationId(6)]);
1246        assert!(std::fs::symlink_metadata(builds.join("a")).is_err(), "committed: the profile roots it");
1247        assert!(std::fs::symlink_metadata(builds.join("b")).is_ok(), "pending: still the build's");
1248        assert!(std::fs::symlink_metadata(paths.gcroots.join("gen-5")).is_err(), "no longer protected");
1249        assert!(std::fs::symlink_metadata(paths.gcroots.join("gen-6")).is_ok(), "protected");
1250    }
1251
1252    /// C4: a build root older than the longest a candidate may wait goes,
1253    /// whoever left it (a crashed build's included); a recent one stays.
1254    #[test]
1255    fn old_build_roots_age_out() {
1256        let d = tempfile::tempdir().unwrap();
1257        let paths = Paths { gcroots: d.path().join("gcroots"), ..Default::default() };
1258        let builds = paths.gcroots.join("builds");
1259        std::fs::create_dir_all(&builds).unwrap();
1260        std::os::unix::fs::symlink("/nix/store/aaa-crashed", builds.join("a")).unwrap();
1261        let n = Nixos::new(paths);
1262        let now = std::time::SystemTime::now();
1263        assert!(n.age_out_roots_at(now).is_empty(), "recent: a build may still be waiting on it");
1264        assert_eq!(n.age_out_roots_at(now + PARKING_GRACE), vec!["a".to_string()]);
1265        assert!(std::fs::symlink_metadata(builds.join("a")).is_err());
1266    }
1267
1268    /// C6: an SRI sha256 decodes to its 32 bytes; anything else is None.
1269    #[test]
1270    fn sri_hashes_decode() {
1271        // sha256 of the empty string.
1272        let h = sri_sha256("sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=").unwrap();
1273        assert_eq!(h.to_hex(), "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855");
1274        assert!(sri_sha256("sha512-AAAA").is_none());
1275        assert!(sri_sha256("sha256-AAAA").is_none(), "too short");
1276    }
1277
1278    /// C6: the NAR hash is read from either shape of `nix path-info --json`.
1279    #[test]
1280    fn nar_hash_from_both_json_shapes() {
1281        let p = "/nix/store/aaa-system";
1282        let object = r#"{"/nix/store/aaa-system": {"narHash": "sha256-AAA", "narSize": 1}}"#;
1283        let array = r#"[{"path": "/nix/store/aaa-system", "narHash": "sha256-AAA"}]"#;
1284        assert_eq!(nar_hash_of(object, p).as_deref(), Some("sha256-AAA"));
1285        assert_eq!(nar_hash_of(array, p).as_deref(), Some("sha256-AAA"));
1286        assert_eq!(nar_hash_of(array, "/nix/store/bbb"), None);
1287    }
1288
1289    fn override_rig() -> (tempfile::TempDir, Nixos, PathBuf) {
1290        let d = tempfile::tempdir().unwrap();
1291        let owner = d.path().join("owner");
1292        std::fs::create_dir_all(owner.join("planarian")).unwrap();
1293        let live = owner.join("planarian/overrides.nix");
1294        std::fs::write(&live, render(&OverrideSet::default())).unwrap();
1295        let paths = Paths { state: d.path().join("state"), owner, ..Default::default() };
1296        let lease = paths.txn_dir().join("9");
1297        std::fs::create_dir_all(&lease).unwrap();
1298        std::fs::write(lease.join("lease"), "1\n").unwrap();
1299        (d, Nixos::new(paths), live)
1300    }
1301
1302    /// I23 (R11): the compare and the rename are one step under the lock on
1303    /// `planarian/`. A cooperating writer holding it, which replaces the file
1304    /// before letting go, is seen: the swap fails and its edit stays.
1305    #[test]
1306    fn the_override_swap_waits_for_the_directory_lock() {
1307        let (_d, n, live) = override_rig();
1308        let from = sha(&std::fs::read_to_string(&live).unwrap());
1309        let mut set = OverrideSet::default();
1310        set.assignments.insert("a.b".into(), Value::Bool(true));
1311        let to = n.remember_override(&set).unwrap();
1312        let (held_tx, held_rx) = std::sync::mpsc::channel();
1313        let writer = {
1314            let live = live.clone();
1315            std::thread::spawn(move || {
1316                let _l = Nixos::lock_dir(live.parent().unwrap()).unwrap();
1317                held_tx.send(()).unwrap();
1318                std::thread::sleep(Duration::from_millis(300));
1319                std::fs::write(&live, "# the owner's edit\n{ }\n").unwrap();
1320            })
1321        };
1322        held_rx.recv().unwrap();
1323        let op = OpId { txn: TxnId(9), epoch: 1, seq: 1 };
1324        let o = n.swap_override(&from, &to, &op);
1325        writer.join().unwrap();
1326        assert!(matches!(o, Outcome::Failed(ref m) if m.contains("changed by someone else")), "{o:?}");
1327        assert_eq!(std::fs::read_to_string(&live).unwrap(), "# the owner's edit\n{ }\n");
1328        // Unlocked and unchanged: the swap goes through.
1329        let from = sha(&std::fs::read_to_string(&live).unwrap());
1330        std::fs::write(n.store_dir().join(format!("{}.nix", from.to_hex())), std::fs::read(&live).unwrap()).unwrap();
1331        assert_eq!(n.swap_override(&from, &to, &op), Outcome::Succeeded);
1332        assert_eq!(sha(&std::fs::read_to_string(&live).unwrap()), to);
1333    }
1334
1335    /// C5: the override store keeps what a kept generation references, the
1336    /// live content and anything recent; the rest is pruned.
1337    #[test]
1338    fn the_override_store_is_pruned() {
1339        let (_d, n, live) = override_rig();
1340        let remember = |k: &str| {
1341            let mut s = OverrideSet::default();
1342            s.assignments.insert(k.into(), Value::Bool(true));
1343            n.remember_override(&s).unwrap().to_hex()
1344        };
1345        let (kept, dropped, gone) = (remember("kept"), remember("dropped"), remember("orphan"));
1346        let live_hex = sha(&std::fs::read_to_string(&live).unwrap()).to_hex();
1347        std::fs::write(n.store_dir().join(format!("{live_hex}.nix")), std::fs::read(&live).unwrap()).unwrap();
1348        n.record_override(GenerationId(1), &Hash::from_hex(&kept).unwrap()).unwrap();
1349        n.record_override(GenerationId(2), &Hash::from_hex(&dropped).unwrap()).unwrap();
1350        let now = std::time::SystemTime::now();
1351        assert!(n.prune_override_store(&[GenerationId(1)], now).is_empty(), "all recent: kept");
1352        let later = now + PARKING_GRACE + Duration::from_secs(60);
1353        let mut want = vec![dropped, gone];
1354        want.sort();
1355        assert_eq!(n.prune_override_store(&[GenerationId(1)], later), want);
1356        for h in [&kept, &live_hex] {
1357            assert!(n.store_dir().join(format!("{h}.nix")).exists(), "{h} kept");
1358        }
1359    }
1360
1361    #[test]
1362    fn closure_sizes_from_both_json_shapes() {
1363        let object = r#"{"/nix/store/a": {"narSize": 100}, "/nix/store/b": {"narSize": 23}}"#;
1364        let array = r#"[{"path": "/nix/store/a", "narSize": 100}, {"path": "/nix/store/b", "narSize": 23}]"#;
1365        assert_eq!(closure_bytes_of(object), Some(123));
1366        assert_eq!(closure_bytes_of(array), Some(123));
1367        assert_eq!(closure_bytes_of("nonsense"), None);
1368    }
1369
1370    /// R10: nonzero exit, timeout and truncated output each refuse the plan.
1371    #[test]
1372    fn a_failed_dry_activation_is_no_plan() {
1373        let o = |status, timed_out, truncated| Output {
1374            status,
1375            stdout: "would start the following units: a.service\n".into(),
1376            stderr: String::new(),
1377            timed_out,
1378            truncated,
1379        };
1380        assert!(dry_activate_failure(&o(Some(0), false, false)).is_none());
1381        assert!(dry_activate_failure(&o(Some(1), false, false)).is_some());
1382        assert!(dry_activate_failure(&o(None, true, false)).is_some());
1383        assert!(dry_activate_failure(&o(Some(0), false, true)).is_some());
1384    }
1385
1386    #[test]
1387    fn dry_activate() {
1388        let text = "would stop the following units: gym-web.service, foo.socket\n\
1389                    would NOT stop the following changed units: getty@tty1.service\n\
1390                    would restart the following units: sshd.service\n\
1391                    would start the following units: avahi-daemon.service, graphical-session.target\n";
1392        let p = parse_dry_activate(text);
1393        assert_eq!(p.stop, [UnitId::service("gym-web")].into());
1394        assert_eq!(p.restart, [UnitId::service("sshd")].into());
1395        assert!(p.start.contains(&UnitId::target("graphical-session")));
1396    }
1397
1398    #[test]
1399    fn registry_ids_are_stable() {
1400        let mut r = Registry::default();
1401        let a = r.id_for("/nix/store/a");
1402        let b = r.id_for("/nix/store/b");
1403        assert_eq!(r.id_for("/nix/store/a"), a);
1404        assert!(b > a);
1405    }
1406
1407    #[test]
1408    fn apply_expression_shape() {
1409        let e = apply_expr(&["services.openssh.enable".into()]);
1410        assert!(e.starts_with("c: { export = c.config.services.planarian.export;"));
1411        assert!(e.contains(r#"[ "services" "openssh" "enable" ]"#));
1412        assert!(e.contains("highestPrio"));
1413    }
1414
1415    #[test]
1416    fn export_roundtrip() {
1417        let e = Export {
1418            units: vec![UnitDef {
1419                id: UnitId::service("sshd"),
1420                desired: Desired { status: DesiredStatus::Running, when: Condition::Always },
1421                edges: vec![],
1422                sensitivity: Sensitivities::of(&[Sensitivity::Security]),
1423                restart_disruption: Disruption::Brief,
1424                probes: vec![],
1425                synonyms: vec!["ssh".into()],
1426                systemd_restart: None,
1427                startup_grace: None,
1428                runtime_repair: None,
1429                stops_are_faults: false,
1430                holds: false,
1431                hold_allow: vec![],
1432                hold_interfaces: vec![],
1433            }],
1434            ..Default::default()
1435        };
1436        let j = serde_json::to_string(&e).unwrap();
1437        assert!(j.contains("\"userUnits\""), "{j}");
1438        assert_eq!(serde_json::from_str::<Export>(&j).unwrap(), e);
1439    }
1440
1441    #[test]
1442    fn stage_copies_tracked_files_and_hashes_like_nix() {
1443        if run(&["nix-hash", "--version"], QUICK).status != Some(0) {
1444            // The package's check sets this, so a missing tool fails there.
1445            assert!(
1446                std::env::var_os("PLANARIAN_REQUIRE_NIX_GIT_TESTS").is_none(),
1447                "PLANARIAN_REQUIRE_NIX_GIT_TESTS is set, and nix-hash is not on PATH"
1448            );
1449            eprintln!("SKIPPED: nix-hash not on PATH");
1450            return;
1451        }
1452        let d = tempfile::tempdir().unwrap();
1453        let owner = d.path().join("owner");
1454        std::fs::create_dir_all(owner.join("planarian")).unwrap();
1455        std::fs::write(owner.join("flake.nix"), "{ }").unwrap();
1456        std::fs::write(owner.join("planarian/overrides.nix"), render(&OverrideSet::default())).unwrap();
1457        let paths = Paths { state: d.path().join("state"), owner: owner.clone(), ..Default::default() };
1458        let n = Nixos::new(paths);
1459        let src = PinnedSource { root: owner.to_string_lossy().into_owned(), kind: SourceKind::Directory };
1460        let mut set = OverrideSet::default();
1461        set.assignments.insert("a.b".into(), Value::Bool(true));
1462        let s = n.stage(&src, &set).unwrap();
1463        assert_ne!(s.base_source, s.staged_source);
1464        assert_eq!(n.source_hash(&src).unwrap(), s.base_source, "owner tree unchanged → not stale");
1465        std::fs::write(owner.join("flake.nix"), "{ edited = 1; }").unwrap();
1466        assert_ne!(n.source_hash(&src).unwrap(), s.base_source, "owner edit → stale");
1467        assert_eq!(
1468            std::fs::read_to_string(PathBuf::from(&s.dir).join("planarian/overrides.nix")).unwrap(),
1469            render(&set)
1470        );
1471        assert_eq!(
1472            std::fs::read_to_string(owner.join("planarian/overrides.nix")).unwrap(),
1473            render(&OverrideSet::default()),
1474            "owner's file untouched"
1475        );
1476    }
1477
1478    #[test]
1479    fn git_owner_edit_after_staging_is_stale() {
1480        if run(&["nix-hash", "--version"], QUICK).status != Some(0)
1481            || run(&["git", "--version"], QUICK).status != Some(0)
1482        {
1483            // The package's check sets this, so a missing tool fails there.
1484            assert!(
1485                std::env::var_os("PLANARIAN_REQUIRE_NIX_GIT_TESTS").is_none(),
1486                "PLANARIAN_REQUIRE_NIX_GIT_TESTS is set, and nix-hash or git is not on PATH"
1487            );
1488            eprintln!("SKIPPED: nix-hash or git not on PATH");
1489            return;
1490        }
1491        let d = tempfile::tempdir().unwrap();
1492        let owner = d.path().join("owner");
1493        std::fs::create_dir_all(owner.join("planarian")).unwrap();
1494        std::fs::write(owner.join("flake.nix"), "{ }").unwrap();
1495        std::fs::write(owner.join("configuration.nix"), "{ }\n").unwrap();
1496        std::fs::write(owner.join("planarian/overrides.nix"), render(&OverrideSet::default())).unwrap();
1497        let o = owner.to_string_lossy().into_owned();
1498        let git = |args: &[&str]| {
1499            let mut a = vec!["git", "-c", "user.email=t@t", "-c", "user.name=t", "-C", &o];
1500            a.extend_from_slice(args);
1501            assert!(run(&a, QUICK).ok(), "{args:?}");
1502        };
1503        git(&["init", "-q"]);
1504        git(&["add", "-A"]);
1505        git(&["commit", "-qm", "owner"]);
1506        let paths = Paths { state: d.path().join("state"), owner: owner.clone(), ..Default::default() };
1507        let n = Nixos::new(paths);
1508        let src = PinnedSource { root: o.clone(), kind: SourceKind::Directory };
1509        let mut set = OverrideSet::default();
1510        set.assignments.insert("a.b".into(), Value::Bool(true));
1511        let s = n.stage(&src, &set).unwrap();
1512        assert_eq!(n.source_hash(&src).unwrap(), s.base_source, "owner tree unchanged → not stale");
1513        // As the gym's edit_owner_source does: edit a tracked file, stage it, don't commit.
1514        std::fs::write(owner.join("configuration.nix"), "{ }\n# edited by the gym\n").unwrap();
1515        git(&["add", "configuration.nix"]);
1516        assert_ne!(n.source_hash(&src).unwrap(), s.base_source, "tracked edit → stale");
1517    }
1518}