Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/Skidbladnir

src/layout.rs

//! AIRGAP5 — the target directory layout.
//!
//! Design (`.nornir/airgap-install.md` §2): a **data dir** (fast-moving data)
//! plus a **software dir** partitioned by airgap **version** (date-stamped, e.g.
//! `software/20250505/`), with **each product under the version**
//! (`software/<YYYYMMDD>/<product>`). Upgrades are an **atomic symlink swap**:
//! `software/current` points at the active version dir, and swapping it is a
//! single `rename(2)` so a reader never sees a half-written tree. Everything is
//! **idempotent** — re-`prepare()` on a partial install just fills the gaps.

use std::path::{Path, PathBuf};

use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};

/// The target's prepared directory layout.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Layout {
    /// Fast-moving data root (e.g. `/var/lib`).
    pub data_dir: PathBuf,
    /// Per-product log root (e.g. `/var/log`). A product's logs live under
    /// `log_dir/<name>` — provisioned alongside its data dir at install time.
    pub log_dir: PathBuf,
    /// Software root; partitioned by airgap version below.
    pub software_dir: PathBuf,
    /// Airgap generation (e.g. "20250505"); software lives under
    /// `software_dir/<version>`.
    pub version: String,
}

impl Layout {
    /// A layout whose `version` is today's date stamp (`YYYYMMDD`), with explicit
    /// data / log / software roots (e.g. `/var/lib`, `/var/log`, `/opt/lake`).
    pub fn dated(
        data_dir: impl Into<PathBuf>,
        log_dir: impl Into<PathBuf>,
        software_dir: impl Into<PathBuf>,
    ) -> Self {
        Layout {
            data_dir: data_dir.into(),
            log_dir: log_dir.into(),
            software_dir: software_dir.into(),
            version: chrono::Utc::now().format("%Y%m%d").to_string(),
        }
    }

    /// **THE ONE TRIPLE — `root/{data,log,software}`, stamped with today's
    /// generation.**
    ///
    /// Every consumer that has a single state root wants exactly these three
    /// directories under it, and until this existed every consumer built them by
    /// hand: `bifrost` in the CLI, korp's `run_refresh`, dwarves' `run_refresh`, and
    /// the install tests — four hand-written copies of one three-line rule (LAW 5).
    /// Four copies is also four places for a rename to half-land.
    ///
    /// Use [`Layout::for_service_user_in`] when the root should be the service
    /// account's home, which is the default this project states as policy.
    pub fn under(root: impl AsRef<Path>) -> Self {
        let root = root.as_ref();
        Layout::dated(root.join("data"), root.join("log"), root.join("software"))
    }

    /// **The stated default: a service's program path and data path both live under
    /// the HOME OF THE ACCOUNT THE SERVICE RUNS AS.**
    ///
    /// > *"Every system for Skidbladnir shall have an installation path for the
    /// > program and an installation path for data. Both default to the home
    /// > directory of the user the service runs as — e.g. `/home/dwarves`."*
    ///
    /// [`Layout`] has always been able to REPRESENT that (three independent roots);
    /// what it could not do was DERIVE it, so every product hardcoded the home it
    /// expected the account to have. A hardcoded `/home/dwarves` is a claim about
    /// `/etc/passwd` that nothing checks — and when the two disagree the service
    /// installs into a directory it will never read from. This resolves the home the
    /// same way the chown guard and the drop-in scanner do
    /// ([`crate::systemd::lookup_system_home_in`], one passwd parser), so all three
    /// agree by construction.
    ///
    /// `sub` nests the whole tree one level deeper (`Some(".korp")` ⇒
    /// `/home/korp/.korp/{data,log,software}`, which is what korp is installed as
    /// today). `None` puts the three roots directly in the home.
    ///
    /// Fails when the account has no passwd row, or its home is relative or `/` — a
    /// layout rooted at `/` would put `data/`, `log/` and `software/` in the
    /// filesystem root, which is the shape of INCIDENT 2026-07-22.
    pub fn for_service_user_in(user: &str, sub: Option<&str>, passwd_path: &Path) -> Result<Self> {
        let home = crate::systemd::lookup_system_home_in(user, passwd_path).ok_or_else(|| {
            anyhow::anyhow!(
                "cannot place a layout for service user `{user}`: no row in {} (create the \
                 account first — `ensure_system_user` does it)",
                passwd_path.display()
            )
        })?;
        if !home.is_absolute() || home == Path::new("/") || home.parent().is_none() {
            anyhow::bail!(
                "service user `{user}` has home `{}`, which cannot root a layout — \
                 data/, log/ and software/ would land in the filesystem root",
                home.display()
            );
        }
        Ok(Layout::under(match sub {
            Some(s) => home.join(s),
            None => home,
        }))
    }

    /// [`Layout::for_service_user_in`] against the live `/etc/passwd`.
    pub fn for_service_user(user: &str, sub: Option<&str>) -> Result<Self> {
        Layout::for_service_user_in(user, sub, Path::new("/etc/passwd"))
    }

    /// The version dir: `software_dir/<version>`.
    pub fn version_dir(&self) -> PathBuf {
        self.software_dir.join(&self.version)
    }

    /// Where product `name`'s software is unpacked: `software_dir/<version>/<name>`.
    pub fn product_software(&self, name: &str) -> PathBuf {
        self.version_dir().join(name)
    }

    /// Where product `name`'s fast-moving data lives: `data_dir/<name>`.
    pub fn product_data(&self, name: &str) -> PathBuf {
        self.data_dir.join(name)
    }

    /// Where product `name`'s logs live: `log_dir/<name>`. Provisioned next to the
    /// product's data dir by [`Layout::prepare_product`] at install time.
    pub fn product_log(&self, name: &str) -> PathBuf {
        self.log_dir.join(name)
    }

    /// The `current` symlink path that points at the active version dir. Callers
    /// resolve software through this so an upgrade is a single atomic swap.
    pub fn current_link(&self) -> PathBuf {
        self.software_dir.join("current")
    }

    /// Does `dir` contain at least one REGULAR FILE, at any depth?
    ///
    /// The question every caller here actually means when it asks "is this a real
    /// install?". `is_dir()` cannot answer it: [`Layout::prepare_product`] creates
    /// `software/<version>/<product>` at the START of an install, before a single
    /// byte is unfolded, and is documented as idempotent and non-clobbering. So an
    /// install that failed — or was killed — anywhere between `prepare_product` and
    /// [`crate::archive::Archive::extract_all`] leaves behind a directory that is
    /// indistinguishable from a finished generation under an existence check, and
    /// stays there forever because nothing cleans it up.
    ///
    /// Iterative with an explicit budget rather than recursive: this runs on the
    /// rollback path, and a symlink loop or a pathological tree must not turn a
    /// recovery into a hang or a stack overflow. Returns on the FIRST file found, so
    /// the common case touches one directory.
    fn holds_any_file(dir: &Path) -> bool {
        const MAX_ENTRIES: usize = 4096;
        let mut stack = vec![dir.to_path_buf()];
        let mut seen = 0usize;
        while let Some(d) = stack.pop() {
            let Ok(rd) = std::fs::read_dir(&d) else {
                continue;
            };
            for entry in rd.flatten() {
                seen += 1;
                if seen > MAX_ENTRIES {
                    // Budget exhausted on a tree this large: it plainly is not the
                    // empty husk we are guarding against.
                    return true;
                }
                // `metadata` follows symlinks — a link to a real file IS payload.
                match entry.metadata() {
                    Ok(m) if m.is_file() => return true,
                    Ok(m) if m.is_dir() => stack.push(entry.path()),
                    _ => {}
                }
            }
        }
        false
    }

    /// Does generation `version` hold a REAL install of `product` — not just the
    /// empty directory a failed install leaves behind?
    ///
    /// This is the predicate [`Layout::previous_generation`] selects on, and the one
    /// that keeps a rollback from promoting a husk. See [`Layout::holds_any_file`]
    /// for why existence is not enough.
    pub fn generation_holds_product(&self, version: &str, product: &str) -> bool {
        Self::holds_any_file(&self.software_dir.join(version).join(product))
    }

    /// Prepare the data + log + `software/<version>` ROOT dirs on disk
    /// (idempotent: existing dirs are left untouched). Does NOT touch the
    /// `current` symlink — call [`Layout::activate`] to swap it after the version's
    /// software is unpacked. Per-PRODUCT `data/<name>` + `log/<name>` dirs are
    /// created by [`Layout::prepare_product`].
    pub fn prepare(&self) -> Result<()> {
        std::fs::create_dir_all(&self.data_dir)
            .with_context(|| format!("create data dir {}", self.data_dir.display()))?;
        std::fs::create_dir_all(&self.log_dir)
            .with_context(|| format!("create log dir {}", self.log_dir.display()))?;
        std::fs::create_dir_all(self.version_dir())
            .with_context(|| format!("create version dir {}", self.version_dir().display()))?;
        Ok(())
    }

    /// Provision product `name`'s per-product tree: its software dir under the
    /// active version (`software/<version>/<name>`) plus BOTH a `data/<name>` and a
    /// `log/<name>` dir — the AIRGAP5 product-install contract ("create data dir +
    /// create log dir"). Calls [`Layout::prepare`] first, so the roots exist too.
    /// Fully idempotent: re-running on a partial install just fills the gaps and
    /// never clobbers an existing data/log dir. Returns the three created paths in
    /// `(software, data, log)` order for the caller to record.
    pub fn prepare_product(&self, name: &str) -> Result<(PathBuf, PathBuf, PathBuf)> {
        self.prepare()?;
        let software = self.product_software(name);
        let data = self.product_data(name);
        let log = self.product_log(name);
        std::fs::create_dir_all(&software)
            .with_context(|| format!("create product software dir {}", software.display()))?;
        std::fs::create_dir_all(&data)
            .with_context(|| format!("create product data dir {}", data.display()))?;
        std::fs::create_dir_all(&log)
            .with_context(|| format!("create product log dir {}", log.display()))?;
        Ok((software, data, log))
    }

    /// Atomically point `software/current` at this layout's version dir — the
    /// upgrade swap. Implemented as write-new-symlink-to-temp + `rename`, which
    /// is atomic on the same filesystem, so a concurrent reader either sees the
    /// old target or the new one, never a missing/partial link. Idempotent.
    #[cfg(unix)]
    pub fn activate(&self) -> Result<()> {
        self.activate_version(&self.version)
    }

    #[cfg(not(unix))]
    pub fn activate(&self) -> Result<()> {
        self.activate_version(&self.version)
    }

    /// Atomically point `software/current` at an EXPLICIT `version` generation —
    /// the generalized swap [`Layout::activate`] delegates to (it passes
    /// `self.version`) and the primitive [`crate::install::rollback_product`] uses
    /// to swap BACK to a prior generation. Same write-new-symlink-to-temp +
    /// `rename` as before, so it stays atomic on the same filesystem and a
    /// concurrent reader never sees a missing/partial link. Guards that the target
    /// generation dir exists on disk (a swap to a missing generation is refused).
    #[cfg(unix)]
    pub fn activate_version(&self, version: &str) -> Result<()> {
        use std::os::unix::fs::symlink;
        let gen_dir = self.software_dir.join(version);
        if !gen_dir.is_dir() {
            anyhow::bail!(
                "cannot activate generation `{version}`: {} does not exist",
                gen_dir.display()
            );
        }
        // `current` decides which code the service execs, and the swap is atomic and
        // self-erasing — once it lands, the generation it replaced is no longer
        // recorded anywhere. So the claim being licensed here ("this generation is a
        // real install") must be checked against the disk, not against the existence
        // of a directory that `prepare_product` creates before any payload arrives.
        // Promoting an empty husk points every later run at nothing, and nothing ever
        // re-asks the question.
        if !Self::holds_any_file(&gen_dir) {
            anyhow::bail!(
                "refusing to activate generation `{version}`: {} contains no files — \
                 it is the empty husk a failed install leaves behind, not a usable \
                 generation. `current` is left untouched.",
                gen_dir.display()
            );
        }
        let link = self.current_link();
        let tmp = self.software_dir.join(".current.tmp");
        let _ = std::fs::remove_file(&tmp);
        // Point at the relative version name so the tree is relocatable.
        symlink(version, &tmp).with_context(|| format!("create temp symlink {}", tmp.display()))?;
        std::fs::rename(&tmp, &link).with_context(|| format!("atomic-swap current → {version}"))?;
        Ok(())
    }

    #[cfg(not(unix))]
    pub fn activate_version(&self, version: &str) -> Result<()> {
        // Windows has no atomic symlink swap in std; record the active version in
        // a small marker file instead (junctions need elevation).
        if !self.software_dir.join(version).is_dir() {
            anyhow::bail!(
                "cannot activate generation `{version}`: {} does not exist",
                self.software_dir.join(version).display()
            );
        }
        std::fs::write(self.software_dir.join("CURRENT"), version)
            .context("write CURRENT marker")?;
        Ok(())
    }

    /// The generation to roll `product` BACK to: the most recent retained version
    /// dir — other than the active one — that still holds this product's software
    /// (`software/<gen>/<product>` exists). Date-stamped names sort chronologically,
    /// so this picks the newest generation strictly older than the active one (or,
    /// if no generation is active, the newest retained one). `None` when there is no
    /// prior generation to roll back to — the guard [`crate::install::rollback_product`]
    /// turns into an error.
    pub fn previous_generation(&self, product: &str) -> Option<String> {
        let active = self.active_version();
        let mut gens: Vec<String> = std::fs::read_dir(&self.software_dir)
            .ok()?
            .filter_map(|e| e.ok())
            .filter_map(|e| e.file_name().into_string().ok())
            // Skip the `current` symlink, the `.current.tmp` scratch, the non-unix
            // `CURRENT` marker, and the currently-active generation.
            .filter(|n| n != "current" && n != "CURRENT" && !n.starts_with('.'))
            .filter(|n| active.as_deref() != Some(n.as_str()))
            // Only a generation that actually retains THIS product is a valid target
            // — and "retains" means HOLDS FILES, not merely has a directory. A failed
            // install leaves `software/<gen>/<product>` empty (prepare_product runs
            // before the unfold), and rollback is the EMERGENCY path: selecting a husk
            // here means the recovery you reach for when something is already broken
            // silently makes it worse, permanently.
            .filter(|n| self.generation_holds_product(n, product))
            .collect();
        gens.sort();
        match &active {
            // Newest generation strictly older than the active one.
            Some(a) => gens.into_iter().rfind(|g| g.as_str() < a.as_str()),
            // No active generation: fall back to the newest retained one.
            None => gens.into_iter().next_back(),
        }
    }

    /// The version the `current` symlink resolves to, if any (the active gen).
    #[cfg(unix)]
    pub fn active_version(&self) -> Option<String> {
        std::fs::read_link(self.current_link())
            .ok()
            .and_then(|p| p.file_name().map(|n| n.to_string_lossy().into_owned()))
    }

    #[cfg(not(unix))]
    pub fn active_version(&self) -> Option<String> {
        std::fs::read_to_string(self.software_dir.join("CURRENT")).ok()
    }
}

/// Resolve a product's software path THROUGH the `current` symlink — what a
/// running service uses so it always follows the active generation.
pub fn current_product_path(software_dir: &Path, product: &str) -> PathBuf {
    software_dir.join("current").join(product)
}

#[cfg(test)]
mod tests {
    use super::*;

    /// LAW 1 (inject-assert): `prepare()` creates the version-dated, per-product
    /// dirs; the path is `software/<version>/<product>` and data is `data/<p>`.
    #[test]
    fn prepare_makes_version_dated_per_product_dirs() {
        let tmp = tempfile::tempdir().unwrap();
        let l = Layout {
            data_dir: tmp.path().join("data"),
            log_dir: tmp.path().join("log"),
            software_dir: tmp.path().join("software"),
            version: "20250505".into(),
        };
        l.prepare().unwrap();

        assert!(l.data_dir.is_dir(), "data dir created");
        assert!(l.log_dir.is_dir(), "log dir created");
        assert!(l.version_dir().is_dir(), "software/<version> created");
        assert_eq!(
            l.product_software("doris"),
            tmp.path().join("software/20250505/doris")
        );
        assert_eq!(l.product_data("doris"), tmp.path().join("data/doris"));
        assert_eq!(l.product_log("doris"), tmp.path().join("log/doris"));

        // Idempotent: a second prepare() does not error.
        l.prepare().unwrap();
    }

    /// LAW 1: `prepare_product` provisions ALL THREE per-product dirs — software
    /// under the active version, a `data/<name>`, and a `log/<name>` — and is
    /// idempotent (a second call over existing dirs, even with data written into
    /// them, is a no-op that never clobbers).
    #[test]
    fn prepare_product_creates_software_data_and_log_dirs() {
        let tmp = tempfile::tempdir().unwrap();
        let l = Layout {
            data_dir: tmp.path().join("data"),
            log_dir: tmp.path().join("log"),
            software_dir: tmp.path().join("software"),
            version: "20250505".into(),
        };

        let (sw, data, log) = l.prepare_product("holger").unwrap();
        assert_eq!(sw, tmp.path().join("software/20250505/holger"));
        assert_eq!(data, tmp.path().join("data/holger"));
        assert_eq!(log, tmp.path().join("log/holger"));
        assert!(sw.is_dir(), "software/<version>/<name> created");
        assert!(data.is_dir(), "data/<name> created");
        assert!(log.is_dir(), "log/<name> created");

        // Write into data + log, then re-run: idempotent, contents preserved.
        std::fs::write(data.join("state.db"), b"rows").unwrap();
        std::fs::write(log.join("app.log"), b"line").unwrap();
        l.prepare_product("holger").unwrap();
        assert_eq!(std::fs::read(data.join("state.db")).unwrap(), b"rows");
        assert_eq!(std::fs::read(log.join("app.log")).unwrap(), b"line");
    }

    /// LAW 1: the atomic symlink swap — `activate()` points `current` at the
    /// version; a SECOND version swaps it atomically; `active_version()` tracks it.
    #[cfg(unix)]
    #[test]
    fn atomic_symlink_swap_on_upgrade() {
        let tmp = tempfile::tempdir().unwrap();
        let sw = tmp.path().join("software");

        let v1 = Layout {
            data_dir: tmp.path().join("data"),
            log_dir: tmp.path().join("log"),
            software_dir: sw.clone(),
            version: "20250505".into(),
        };
        v1.prepare().unwrap();
        // NB: this used to be
        //   fs::write(v1.product_software("holger").join("../holger.bin"), b"v1").ok();
        // which silently did NOTHING — `prepare()` (unlike `prepare_product`) never
        // creates the `holger/` component, so the path had no parent and the write
        // failed; `.ok()` threw the error away. The fixture was never set up, and no
        // assertion depended on it, so nobody noticed. Write it where it belongs, and
        // unwrap so a broken fixture fails loudly instead of quietly.
        std::fs::write(v1.version_dir().join("holger.bin"), b"v1").unwrap();
        v1.activate().unwrap();
        assert_eq!(v1.active_version().as_deref(), Some("20250505"));
        assert!(v1.current_link().exists(), "current resolves");

        // Upgrade: prepare a NEW version dir and atomically swap `current`.
        let v2 = Layout {
            version: "20250606".into(),
            ..v1.clone()
        };
        v2.prepare().unwrap();
        // A generation must hold real software before it can be made live (an empty
        // dir is the husk a failed install leaves) — this test is about the SWAP, so
        // give it something to swap to.
        std::fs::write(v2.version_dir().join("holger.bin"), b"v2").unwrap();
        v2.activate().unwrap();
        assert_eq!(v2.active_version().as_deref(), Some("20250606"), "swapped");
        // The old version dir is untouched (rollback target).
        assert!(
            sw.join("20250505").is_dir(),
            "old gen retained for rollback"
        );
        // current now resolves through the new gen.
        assert_eq!(
            current_product_path(&sw, "holger"),
            sw.join("current/holger")
        );
    }

    /// LAW 1: the atomic swap-BACK — after two generations exist and `current`
    /// points at the newer one, `previous_generation` finds the older gen that still
    /// holds the product and `activate_version` swaps `current` back to it atomically.
    #[cfg(unix)]
    #[test]
    fn previous_generation_and_activate_version_roll_back() {
        let tmp = tempfile::tempdir().unwrap();
        let sw = tmp.path().join("software");

        let a = Layout {
            data_dir: tmp.path().join("data"),
            log_dir: tmp.path().join("log"),
            software_dir: sw.clone(),
            version: "20250505".into(),
        };
        a.prepare_product("holger").unwrap();
        std::fs::write(a.product_software("holger").join("holger"), b"a").unwrap();
        a.activate().unwrap();

        let b = Layout {
            version: "20250606".into(),
            ..a.clone()
        };
        b.prepare_product("holger").unwrap();
        std::fs::write(b.product_software("holger").join("holger"), b"b").unwrap();
        b.activate().unwrap();
        assert_eq!(b.active_version().as_deref(), Some("20250606"));

        // The prior generation for holger is A (older than the active B, holds holger).
        assert_eq!(b.previous_generation("holger").as_deref(), Some("20250505"));
        // A product that never existed in an older gen has no rollback target.
        assert_eq!(b.previous_generation("ghost"), None);

        // Swap back to A atomically; current now resolves through A.
        b.activate_version("20250505").unwrap();
        assert_eq!(
            b.active_version().as_deref(),
            Some("20250505"),
            "rolled back to A"
        );
        assert!(
            sw.join("20250606/holger").is_dir(),
            "B still retained after swap-back"
        );

        // Guarding: activating a generation that isn't on disk is refused.
        assert!(
            b.activate_version("20991231").is_err(),
            "missing gen is refused"
        );
        assert_eq!(
            b.active_version().as_deref(),
            Some("20250505"),
            "current untouched on error"
        );
    }

    /// `Layout::dated` stamps an 8-digit YYYYMMDD version, and all path builders
    /// compose off it (pure path math — no disk).
    #[test]
    fn dated_version_is_yyyymmdd_and_paths_compose() {
        let l = Layout::dated("/var/lib", "/var/log", "/opt/sw");
        assert_eq!(l.version.len(), 8, "YYYYMMDD: {}", l.version);
        assert!(l.version.chars().all(|c| c.is_ascii_digit()));
        assert_eq!(l.version_dir(), Path::new("/opt/sw").join(&l.version));
        assert_eq!(
            l.product_software("holger"),
            Path::new("/opt/sw").join(&l.version).join("holger")
        );
        assert_eq!(l.product_data("holger"), Path::new("/var/lib/holger"));
        assert_eq!(l.product_log("holger"), Path::new("/var/log/holger"));
        assert_eq!(l.current_link(), Path::new("/opt/sw/current"));
    }

    const PASSWD: &str = "\
root:x:0:0:root:/root:/bin/bash
alice:x:1000:1000:Alice:/home/alice:/bin/bash
dwarves:x:110:110::/home/dwarves:/usr/sbin/nologin
korp:x:111:111::/home/korp:/usr/sbin/nologin
nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin
rooted:x:112:112::/:/usr/sbin/nologin
relative:x:113:113::not/absolute:/usr/sbin/nologin
";

    fn passwd_fixture() -> (tempfile::TempDir, PathBuf) {
        let tmp = tempfile::tempdir().unwrap();
        let p = tmp.path().join("passwd");
        std::fs::write(&p, PASSWD).unwrap();
        (tmp, p)
    }

    /// `under` IS the triple every consumer used to hand-write. Pure path math.
    #[test]
    fn under_is_the_one_data_log_software_triple() {
        let l = Layout::under("/home/dwarves/.dwarves");
        assert_eq!(l.data_dir, Path::new("/home/dwarves/.dwarves/data"));
        assert_eq!(l.log_dir, Path::new("/home/dwarves/.dwarves/log"));
        assert_eq!(l.software_dir, Path::new("/home/dwarves/.dwarves/software"));
        assert_eq!(l.version.len(), 8, "dated: {}", l.version);
    }

    /// ★ The stated rule: the roots come from the SERVICE ACCOUNT'S HOME, read out of
    /// passwd — not from a constant the product happens to believe.
    ///
    /// RED WHEN REVERTED: hardcode `/home/dwarves` here and the `elsewhere` case below
    /// still returns `/home/dwarves`, i.e. a layout pointing at a directory the
    /// account does not own and the service will never read from. That is exactly the
    /// failure a hardcoded home cannot detect.
    #[test]
    fn for_service_user_reads_the_home_out_of_passwd() {
        let (_t, passwd) = passwd_fixture();

        let l = Layout::for_service_user_in("dwarves", None, &passwd).unwrap();
        assert_eq!(l.data_dir, Path::new("/home/dwarves/data"));
        assert_eq!(l.log_dir, Path::new("/home/dwarves/log"));
        assert_eq!(l.software_dir, Path::new("/home/dwarves/software"));

        // `sub` nests it — korp's live shape, expressed through the SAME primitive
        // instead of a second hand-written triple.
        let k = Layout::for_service_user_in("korp", Some(".korp"), &passwd).unwrap();
        assert_eq!(k.software_dir, Path::new("/home/korp/.korp/software"));
        assert_eq!(
            current_product_path(&k.software_dir, "korp-server"),
            Path::new("/home/korp/.korp/software/current/korp-server"),
            "this is the ExecStart= path korp-server.service carries on oden today"
        );

        // An account whose home is somewhere ELSE follows the account, not a constant.
        let tmp = tempfile::tempdir().unwrap();
        let other = tmp.path().join("passwd");
        std::fs::write(
            &other,
            "dwarves:x:110:110::/srv/dwarves:/usr/sbin/nologin\n",
        )
        .unwrap();
        let moved = Layout::for_service_user_in("dwarves", None, &other).unwrap();
        assert_eq!(
            moved.data_dir,
            Path::new("/srv/dwarves/data"),
            "the layout must follow passwd; a hardcoded /home/dwarves is a claim nothing checks"
        );
    }

    /// The three refusals. INCIDENT 2026-07-22 was a service home that defaulted to
    /// `/`; a layout rooted there puts `data/`, `log/` and `software/` in the
    /// filesystem root, so it is refused by name rather than built and used.
    #[test]
    fn for_service_user_refuses_a_missing_or_unusable_home() {
        let (_t, passwd) = passwd_fixture();

        let e = Layout::for_service_user_in("ghost", None, &passwd)
            .expect_err("an account with no passwd row cannot root a layout");
        assert!(e.to_string().contains("no row in"), "must say WHY: {e}");

        let e = Layout::for_service_user_in("rooted", None, &passwd)
            .expect_err("home `/` must be refused");
        assert!(
            e.to_string().contains("filesystem root"),
            "must name the 2026-07-22 shape: {e}"
        );

        assert!(
            Layout::for_service_user_in("relative", None, &passwd).is_err(),
            "a relative home must be refused"
        );
    }

    /// `active_version()` is None before any `activate()`, and a `Layout`
    /// round-trips through serde (a product embeds it in its airgap bundle).
    #[cfg(unix)]
    #[test]
    fn active_version_none_before_activate_and_serde_roundtrip() {
        let tmp = tempfile::tempdir().unwrap();
        let l = Layout {
            data_dir: tmp.path().join("data"),
            log_dir: tmp.path().join("log"),
            software_dir: tmp.path().join("software"),
            version: "20250101".into(),
        };
        l.prepare().unwrap();
        assert_eq!(l.active_version(), None, "no current link yet");

        let back: Layout = serde_json::from_str(&serde_json::to_string(&l).unwrap()).unwrap();
        assert_eq!(back, l);
    }

    /// Re-`activate()`-ing the SAME version is idempotent — the link still resolves
    /// there (the atomic temp-symlink swap tolerates an existing target).
    #[cfg(unix)]
    #[test]
    fn reactivate_same_version_is_idempotent() {
        let tmp = tempfile::tempdir().unwrap();
        let l = Layout {
            data_dir: tmp.path().join("data"),
            log_dir: tmp.path().join("log"),
            software_dir: tmp.path().join("software"),
            version: "20250101".into(),
        };
        l.prepare().unwrap();
        std::fs::write(l.version_dir().join("holger.bin"), b"payload").unwrap();
        l.activate().unwrap();
        l.activate().unwrap();
        assert_eq!(l.active_version().as_deref(), Some("20250101"));
        assert!(l.current_link().exists());
    }

    // ─────────── promotion on the strength of an unverified claim ───────────

    /// Build a generation dir the way a FAILED install leaves it: `prepare_product`
    /// has run (so `software/<gen>/<product>` exists) but the unfold never landed a
    /// byte. This is the exact fixture the old `is_dir()` checks accepted.
    fn husk_generation(root: &Path, version: &str, product: &str) -> Layout {
        let l = Layout {
            data_dir: root.join("data"),
            log_dir: root.join("log"),
            software_dir: root.join("software"),
            version: version.into(),
        };
        l.prepare_product(product).unwrap();
        l
    }

    /// A generation holding a real file — a finished install.
    fn real_generation(root: &Path, version: &str, product: &str) -> Layout {
        let l = husk_generation(root, version, product);
        std::fs::write(l.product_software(product).join("holger"), b"#!/bin/sh\n").unwrap();
        l
    }

    /// **RED-PROVE: `current` must not be promoted to an empty husk.**
    ///
    /// `prepare_product` creates `software/<version>/<product>` BEFORE the unfold, is
    /// idempotent, and never clobbers — so an install killed between those two steps
    /// leaves a directory that an existence check cannot tell from a finished
    /// generation, and nothing ever cleans it up. `activate_version` guarded only
    /// `is_dir()`, so it would point `current` — the thing that decides which code the
    /// service execs — at a directory containing nothing.
    ///
    /// The swap is atomic AND self-erasing: once it lands, what `current` used to
    /// point at is recorded nowhere. That is what makes this persist.
    #[cfg(unix)]
    #[test]
    fn activate_refuses_an_empty_generation_and_leaves_current_untouched() {
        let tmp = tempfile::tempdir().unwrap();
        let good = real_generation(tmp.path(), "20250505", "holger");
        good.activate().unwrap();
        assert_eq!(good.active_version().as_deref(), Some("20250505"));

        // The husk: exists, is a directory, contains nothing.
        let husk = husk_generation(tmp.path(), "20250606", "holger");
        assert!(
            husk.software_dir.join("20250606").is_dir(),
            "the old `is_dir()` guard is satisfied — the premise of this test",
        );

        let err = husk
            .activate()
            .expect_err("an empty generation must not become current");
        assert!(
            err.to_string().contains("contains no files"),
            "the refusal must name WHY, got: {err}",
        );
        // The critical half: the failure is inert. `current` still resolves to the
        // last good generation, so the service keeps running the code it was running.
        assert_eq!(
            good.active_version().as_deref(),
            Some("20250505"),
            "a refused activation must leave `current` exactly as it was",
        );
    }

    /// **RED-PROVE: a rollback must not select an empty husk as its target.**
    ///
    /// Rollback is the EMERGENCY path — `install::rollback_product` is what runs when
    /// an install has already gone wrong. `previous_generation` filtered candidates on
    /// `software/<gen>/<product>.is_dir()`, so a husk left by an EARLIER failed install
    /// was a valid rollback target. The recovery you reach for when something is
    /// broken would then point `current` at nothing.
    #[cfg(unix)]
    #[test]
    fn previous_generation_skips_husks_and_picks_the_newest_real_one() {
        let tmp = tempfile::tempdir().unwrap();
        let old_real = real_generation(tmp.path(), "20250101", "holger");
        let _husk = husk_generation(tmp.path(), "20250505", "holger");
        let active = real_generation(tmp.path(), "20250606", "holger");
        active.activate().unwrap();

        // The husk is NEWER than the good generation, so an existence-based filter
        // picks it — it sorts last among the non-active candidates.
        assert!(
            active.software_dir.join("20250505").join("holger").is_dir(),
            "the husk satisfies the old `is_dir()` filter — the premise of this test",
        );

        assert_eq!(
            active.previous_generation("holger").as_deref(),
            Some("20250101"),
            "rollback must skip the empty husk and land on the newest REAL generation",
        );
        assert!(
            !active.generation_holds_product("20250505", "holger"),
            "husk holds nothing"
        );
        assert!(
            active.generation_holds_product("20250101", "holger"),
            "real gen holds files"
        );

        // And the rollback that follows actually works.
        active.activate_version("20250101").unwrap();
        assert_eq!(old_real.active_version().as_deref(), Some("20250101"));
    }

    /// Payload nested below the product dir still counts — an unfolded tree does not
    /// have to put a file at the top level. Guards against a fix so strict it refuses
    /// real generations, which would break every rollback instead of fixing it.
    #[cfg(unix)]
    #[test]
    fn a_generation_whose_payload_is_nested_still_counts_as_real() {
        let tmp = tempfile::tempdir().unwrap();
        let l = husk_generation(tmp.path(), "20250707", "holger");
        let nested = l.product_software("holger").join("bin/deep");
        std::fs::create_dir_all(&nested).unwrap();
        std::fs::write(nested.join("holger"), b"payload").unwrap();

        assert!(
            l.generation_holds_product("20250707", "holger"),
            "nested payload is payload"
        );
        l.activate().expect("a real generation activates");
        assert_eq!(l.active_version().as_deref(), Some("20250707"));
    }

    /// An empty directory tree — dirs all the way down, no files — is still a husk.
    /// `extract_all` creates the tree before it writes leaves, so this is the shape of
    /// an unfold interrupted partway.
    #[cfg(unix)]
    #[test]
    fn directories_without_files_are_still_a_husk() {
        let tmp = tempfile::tempdir().unwrap();
        let l = husk_generation(tmp.path(), "20250808", "holger");
        std::fs::create_dir_all(l.product_software("holger").join("bin/deep/deeper")).unwrap();

        assert!(
            !l.generation_holds_product("20250808", "holger"),
            "directories are not payload — only files are",
        );
        assert!(
            l.activate().is_err(),
            "an all-directories tree must not become current"
        );
    }
}