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"
);
}
}