src/hostfamily.rs
//! **What KIND of host is this** — the distro/init/logind concept skidbladnir did
//! not have, and the reason [`crate::rootless`] can refuse instead of guess.
//!
//! # Why this exists
//!
//! Until now the crate modelled the *supervisor* ([`crate::service::Backend`] —
//! `SystemdSystem` / `SystemdUser` / `OpenRc` / `Kubernetes`) and nothing else.
//! [`crate::platform::Platform`] looks like a distro type but is not one: it has
//! three values (`Rhel` / `Debian` / `Other`), exists solely to decide whether the
//! SELinux toggle applies, and puts **Alpine in `Other`** — which is precisely the
//! host where the interesting difference lives. `matrix.rs`'s `OsImage.name` is a
//! free string used for reporting.
//!
//! So nothing in the crate could answer: which package supplies `newuidmap`, is
//! there a logind on this box at all, is `/etc` writable on this distro. Those are
//! the questions a rootless-container host setup is made of.
//!
//! # The design decision that matters: family does NOT determine init
//!
//! The tempting model is one enum where `Debian ⇒ systemd` and `Alpine ⇒ OpenRC`.
//! It is wrong, and wrong in ways that ship:
//!
//! * **Devuan** is `ID_LIKE=debian` and runs **sysvinit** — a systemd assumption
//! derived from the family is simply false there.
//! * **Gentoo** ships an OpenRC profile *and* a systemd profile from the same `ID`.
//! * **Alpine** has no logind by default, but `elogind` is a real, packaged,
//! in-repo option that implements the very `org.freedesktop.login1` interface
//! lingering is set through — so "Alpine cannot linger" is false whenever
//! somebody installed it.
//!
//! Therefore: **[`Family`] is parsed from `/etc/os-release` and says what to
//! INSTALL; [`Init`] and [`Logind`] are PROBED from the running host and say what
//! is actually there.** A family's [`Family::expected_init`] is documentation, not
//! authority — nothing in [`crate::rootless`] branches on it.
//!
//! # Everything here is pure
//!
//! [`HostFacts`] is a plain struct of strings and bools. [`HostFacts::probe`] is
//! the only function that touches the filesystem; every classification below is a
//! total function of the facts, so the whole family/init/logind matrix is
//! unit-testable against fixtures without booting a single VM. (`matrix.rs` is
//! where a real multi-OS boot would prove the probe itself — that is a separate,
//! much more expensive assertion.)
use std::path::Path;
// ---------------------------------------------------------------------------
// Package managers
// ---------------------------------------------------------------------------
/// The package manager a family installs with.
///
/// Carried as a TYPE rather than a string because the one thing skidbladnir
/// legitimately needs it for is naming the package that supplies a missing
/// prerequisite in an error the operator can act on. It is deliberately NOT a
/// driver: this crate does not install packages (that is a host decision, and
/// every one of these is a subprocess under the zero-shell law).
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum PackageManager {
/// Debian family — `apt-get install <pkg>`.
Apt,
/// RHEL family — `dnf install <pkg>` (`yum` on EL7 and older).
Dnf,
/// Alpine — `apk add <pkg>`.
Apk,
/// SUSE family — `zypper install <pkg>`.
Zypper,
/// Arch family — `pacman -S <pkg>`.
Pacman,
/// Gentoo — `emerge <pkg>`.
Portage,
/// Void — `xbps-install <pkg>`.
Xbps,
/// NixOS — no imperative install; the package is declared in the system
/// configuration and materialised by `nixos-rebuild`.
Nix,
}
impl PackageManager {
/// The command an operator would type. Returned as text to PRINT, never to run.
pub fn install_command(self, pkg: &str) -> String {
match self {
PackageManager::Apt => format!("apt-get install -y {pkg}"),
PackageManager::Dnf => format!("dnf install -y {pkg}"),
PackageManager::Apk => format!("apk add {pkg}"),
PackageManager::Zypper => format!("zypper install -y {pkg}"),
PackageManager::Pacman => format!("pacman -S --noconfirm {pkg}"),
PackageManager::Portage => format!("emerge {pkg}"),
PackageManager::Xbps => format!("xbps-install -y {pkg}"),
PackageManager::Nix => format!(
"add `{pkg}` to environment.systemPackages and run nixos-rebuild switch"
),
}
}
}
// ---------------------------------------------------------------------------
// The family
// ---------------------------------------------------------------------------
/// A distro **family** — the packaging lineage, parsed from `/etc/os-release`.
///
/// The roster is not "the four the owner named plus whatever": each member earns its
/// place by differing on a fact [`crate::rootless`] actually reads.
///
/// | family | packages | `newuidmap` from | why it is a separate arm |
/// |---|---|---|---|
/// | [`Debian`](Family::Debian) | apt | `uidmap` | the tools are a SEPARATE package and are NOT installed by default — the single most common cause of a failed rootless setup |
/// | [`Rhel`](Family::Rhel) | dnf | `shadow-utils` | same tools, different package name, and normally already present |
/// | [`Suse`](Family::Suse) | zypper | `shadow` | third spelling of the same package |
/// | [`Arch`](Family::Arch) | pacman | `shadow` | `shadow` is in `base`, so the tools are always there |
/// | [`Alpine`](Family::Alpine) | apk | `shadow-uidmap` | **OpenRC, no logind** — the whole lingering mechanism is absent, see [`Logind`] |
/// | [`Gentoo`](Family::Gentoo) | portage | `shadow` | OpenRC *or* systemd from one `ID`: proof that family cannot imply init |
/// | [`Void`](Family::Void) | xbps | `shadow` | runit — a THIRD init, so "systemd or OpenRC" is not the whole world |
/// | [`NixOs`](Family::NixOs) | nix | (declared) | `/etc/subuid` is GENERATED; writing it is reverted on the next rebuild |
///
/// Anything else is [`Family::Unknown`], carrying the `ID` it saw. That is a
/// refusal, not a fallback — see [`crate::rootless`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Family {
/// Debian, Ubuntu, Linux Mint, Pop!_OS, Raspbian, Devuan …
Debian,
/// RHEL, Fedora, CentOS/Stream, Rocky, AlmaLinux, Oracle Linux, Amazon Linux …
Rhel,
/// openSUSE Leap/Tumbleweed, SLES, SLE Micro …
Suse,
/// Arch, Manjaro, EndeavourOS, CachyOS …
Arch,
/// Alpine.
Alpine,
/// Gentoo (and Funtoo).
Gentoo,
/// Void.
Void,
/// NixOS.
NixOs,
/// An `ID` this crate does not model, carried verbatim so the refusal can name it.
Unknown(String),
}
impl Family {
/// Detect from the real `/etc/os-release`.
pub fn detect() -> Family {
Family::from_os_release(&read_os_release())
}
/// Parse a family from `/etc/os-release` CONTENTS (pure → fixture-testable).
///
/// `ID` is consulted first and `ID_LIKE` only as a fallback, which is the order
/// the freedesktop spec intends and the order that matters here: Ubuntu's
/// `ID=ubuntu ID_LIKE=debian` and Rocky's `ID=rocky ID_LIKE="rhel centos fedora"`
/// both resolve on `ID_LIKE`, while a derivative that shares `ID_LIKE` with a
/// family it does NOT share packaging with resolves on its own `ID` first.
///
/// Note this does NOT do what [`crate::platform::Platform::from_os_release`]
/// does — that one substring-matches the whole file, so a `HOME_URL` mentioning
/// `debian` would decide it. Here the keys are parsed.
pub fn from_os_release(contents: &str) -> Family {
let id = os_release_value(contents, "ID").unwrap_or_default();
if let Some(f) = Family::from_id(&id) {
return f;
}
for like in os_release_value(contents, "ID_LIKE")
.unwrap_or_default()
.split_whitespace()
{
if let Some(f) = Family::from_id(like) {
return f;
}
}
Family::Unknown(if id.is_empty() {
"<no ID in /etc/os-release>".to_string()
} else {
id
})
}
/// One `ID` (or one `ID_LIKE` word) → a family, or `None` if unmodelled.
fn from_id(id: &str) -> Option<Family> {
match id.trim().trim_matches('"').to_ascii_lowercase().as_str() {
"debian" | "ubuntu" | "linuxmint" | "pop" | "raspbian" | "devuan" | "elementary"
| "kali" | "neon" => Some(Family::Debian),
"rhel" | "fedora" | "centos" | "rocky" | "almalinux" | "ol" | "oracle" | "amzn"
| "amazon" | "scientific" => Some(Family::Rhel),
"opensuse" | "opensuse-leap" | "opensuse-tumbleweed" | "sles" | "sled"
| "sle-micro" | "suse" => Some(Family::Suse),
"arch" | "archarm" | "manjaro" | "endeavouros" | "cachyos" | "garuda" => {
Some(Family::Arch)
}
"alpine" => Some(Family::Alpine),
"gentoo" | "funtoo" => Some(Family::Gentoo),
"void" => Some(Family::Void),
"nixos" => Some(Family::NixOs),
_ => None,
}
}
/// A short stable identifier for reports and test names.
pub fn id(&self) -> &str {
match self {
Family::Debian => "debian",
Family::Rhel => "rhel",
Family::Suse => "suse",
Family::Arch => "arch",
Family::Alpine => "alpine",
Family::Gentoo => "gentoo",
Family::Void => "void",
Family::NixOs => "nixos",
Family::Unknown(id) => id,
}
}
/// The family's package manager — `None` for [`Family::Unknown`], because a
/// guess here would produce an install command that does not exist.
pub fn package_manager(&self) -> Option<PackageManager> {
Some(match self {
Family::Debian => PackageManager::Apt,
Family::Rhel => PackageManager::Dnf,
Family::Suse => PackageManager::Zypper,
Family::Arch => PackageManager::Pacman,
Family::Alpine => PackageManager::Apk,
Family::Gentoo => PackageManager::Portage,
Family::Void => PackageManager::Xbps,
Family::NixOs => PackageManager::Nix,
Family::Unknown(_) => return None,
})
}
/// **Which package supplies `newuidmap`/`newgidmap`** — the setuid helpers
/// without which a rootless container gets exactly one uid (its own) and any
/// image whose files are owned by a second uid fails to unpack.
///
/// The names genuinely differ per family and this is the fact most worth having
/// typed: on Debian/Ubuntu the tools live in a package (`uidmap`) that is NOT
/// pulled in by a default install, which is why "podman is installed" and
/// "rootless podman works" are different statements there and largely the same
/// statement on Arch.
pub fn uidmap_package(&self) -> Option<&'static str> {
Some(match self {
Family::Debian => "uidmap",
Family::Rhel => "shadow-utils",
Family::Suse => "shadow",
Family::Arch => "shadow",
Family::Alpine => "shadow-uidmap",
Family::Gentoo => "sys-apps/shadow",
Family::Void => "shadow",
// NixOS enables the helpers through a config option rather than a
// package the operator installs; naming a package would be wrong advice.
Family::NixOs => return None,
Family::Unknown(_) => return None,
})
}
/// The package that would give a logind-less host the `org.freedesktop.login1`
/// interface — i.e. the ability to linger at all. `None` where the family
/// already has systemd-logind by default.
pub fn elogind_package(&self) -> Option<&'static str> {
match self {
Family::Alpine => Some("elogind"),
Family::Gentoo => Some("sys-auth/elogind"),
Family::Void => Some("elogind"),
_ => None,
}
}
/// The package that supplies a ROOTLESS container engine — and with it the
/// user-scope `podman.socket` unit [`crate::rootless::Step::ContainerSocket`]
/// enables. `None` where this crate does not know the name and will not guess one,
/// which is the same rule [`Family::uidmap_package`] follows.
///
/// podman rather than docker, deliberately: the socket this step enables lives in a
/// USER manager, and podman is the engine that ships one as a user unit on every
/// family below. Rootless docker exists, but it is set up per-account by its own
/// script (`dockerd-rootless-setuptool.sh`) — that is not a package name, and not
/// something a declaration can stand behind.
pub fn container_engine_package(&self) -> Option<&'static str> {
Some(match self {
Family::Debian => "podman",
Family::Rhel => "podman",
Family::Suse => "podman",
Family::Arch => "podman",
Family::Alpine => "podman",
Family::Gentoo => "app-containers/podman",
Family::Void => "podman",
// Same reason as `uidmap_package`: NixOS turns this on through a config
// option (`virtualisation.podman.enable`), so naming a package to install
// would be wrong advice rather than merely unhelpful.
Family::NixOs => return None,
Family::Unknown(_) => return None,
})
}
/// **The init this family ships by default — DOCUMENTATION, never authority.**
///
/// Nothing in [`crate::rootless`] branches on this; it branches on
/// [`Init::probe`]. It is here so a report can say "expected systemd, found
/// OpenRC", which is a far more useful sentence than either fact alone, and so
/// the Devuan/Gentoo counterexamples have somewhere to be written down.
pub fn expected_init(&self) -> Init {
match self {
Family::Debian | Family::Rhel | Family::Suse | Family::Arch | Family::NixOs => {
Init::Systemd
}
Family::Alpine | Family::Gentoo => Init::OpenRc,
Family::Void => Init::Runit,
Family::Unknown(_) => Init::Unknown,
}
}
/// **Is `/etc` GENERATED on this distro?** — true only for NixOS.
///
/// This is not a stylistic note. `/etc/subuid` on NixOS is produced from the
/// system configuration (`users.users.<name>.subUidRanges` / `autoSubUidGidRange`)
/// and materialised into the store; a hand-appended line survives until the next
/// `nixos-rebuild` and then silently vanishes, taking every container that
/// depended on it. A setup tool that "succeeds" there is worse than one that
/// refuses, so [`crate::rootless`] refuses.
pub fn etc_is_generated(&self) -> bool {
matches!(self, Family::NixOs)
}
/// Bridge to the pre-existing narrow [`crate::platform::Platform`] (the SELinux
/// switch). Kept one-way and explicit rather than collapsing the two types:
/// `Platform` answers "does `setenforce` apply", this answers "what host is
/// this", and only the first three families have an opinion the older type can
/// represent.
pub fn platform(&self) -> crate::platform::Platform {
match self {
Family::Rhel => crate::platform::Platform::Rhel,
Family::Debian => crate::platform::Platform::Debian,
_ => crate::platform::Platform::Other,
}
}
}
impl std::fmt::Display for Family {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.id())
}
}
/// Read one `KEY=value` out of `/etc/os-release` text, unquoting a `"…"` value.
///
/// A real parser rather than `contents.contains("debian")`, because os-release is
/// full of URLs and pretty names that contain other distros' names.
fn os_release_value(contents: &str, key: &str) -> Option<String> {
for line in contents.lines() {
let line = line.trim();
if let Some(rest) = line.strip_prefix(key) {
if let Some(v) = rest.strip_prefix('=') {
return Some(v.trim().trim_matches('"').trim_matches('\'').to_string());
}
}
}
None
}
fn read_os_release() -> String {
// `/etc/os-release` is the symlink; `/usr/lib/os-release` is the real file and
// the documented fallback for a host with a stripped `/etc`.
std::fs::read_to_string("/etc/os-release")
.or_else(|_| std::fs::read_to_string("/usr/lib/os-release"))
.unwrap_or_default()
}
// ---------------------------------------------------------------------------
// Init + logind — PROBED, not inferred
// ---------------------------------------------------------------------------
/// The init/service manager actually running as PID 1.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Init {
Systemd,
OpenRc,
Runit,
SysVinit,
/// Nothing recognisable — a container with PID 1 = the app, a chroot, a distro
/// with an init this crate does not model.
Unknown,
}
impl Init {
/// Probe the running host.
pub fn probe() -> Init {
Init::from_facts(&HostFacts::probe())
}
/// Classify from facts (pure).
///
/// The systemd test is the one `sd_booted(3)` documents — the existence of the
/// directory `/run/systemd/system` — and NOT "is there a `/usr/bin/systemctl`",
/// which is true on any box where somebody installed the package.
pub fn from_facts(f: &HostFacts) -> Init {
if f.systemd_run_system {
Init::Systemd
} else if f.openrc_softlevel {
Init::OpenRc
} else if f.runit_runsvdir {
Init::Runit
} else if f.sysvinit_initctl {
Init::SysVinit
} else {
Init::Unknown
}
}
pub fn id(self) -> &'static str {
match self {
Init::Systemd => "systemd",
Init::OpenRc => "openrc",
Init::Runit => "runit",
Init::SysVinit => "sysvinit",
Init::Unknown => "unknown",
}
}
/// Whether this init has per-user service managers at all — the thing a
/// `systemctl --user enable --now podman.socket` presupposes.
pub fn has_user_manager(self) -> bool {
matches!(self, Init::Systemd)
}
}
impl std::fmt::Display for Init {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.id())
}
}
/// **Which login manager, if any, is running** — the single fact that decides
/// whether "linger" is a concept on this host.
///
/// `loginctl enable-linger` is not a generic Linux operation. It is a method on
/// `org.freedesktop.login1.Manager`, and lingering means one specific thing: the
/// login manager keeps a `user@<uid>.service` and a `/run/user/<uid>` alive for an
/// account with no session. Two implementations provide that interface —
/// systemd-logind and elogind, the latter being systemd-logind extracted to run
/// under OpenRC or runit — and on a host with neither, the operation does not
/// exist and no amount of interface design makes it exist.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Logind {
/// systemd-logind.
Systemd,
/// elogind — the standalone port. Same bus name, same `SetUserLinger` method,
/// same `/var/lib/elogind/linger` marker semantics (under its own state dir).
Elogind,
}
impl Logind {
/// Probe the running host; `None` means there is no login manager and therefore
/// no lingering.
pub fn probe() -> Option<Logind> {
Logind::from_facts(&HostFacts::probe())
}
/// Classify from facts (pure).
pub fn from_facts(f: &HostFacts) -> Option<Logind> {
if f.systemd_seats {
Some(Logind::Systemd)
} else if f.elogind_run {
Some(Logind::Elogind)
} else {
None
}
}
/// Where the implementation persists the per-account linger marker.
///
/// VERIFIED on t14s (Ubuntu 26.04, systemd): `/var/lib/systemd/linger/alice`
/// is a zero-byte file, mode 0644 in a 0755 directory — so the state is
/// READABLE without root and without a bus connection, which is what lets
/// [`crate::rootless`] answer "is this host already set up?" from an
/// unprivileged process.
pub fn linger_dir(self) -> &'static str {
match self {
Logind::Systemd => "/var/lib/systemd/linger",
Logind::Elogind => "/var/lib/elogind/linger",
}
}
pub fn id(self) -> &'static str {
match self {
Logind::Systemd => "systemd-logind",
Logind::Elogind => "elogind",
}
}
}
// ---------------------------------------------------------------------------
// The facts
// ---------------------------------------------------------------------------
/// **Everything the classification above reads, as plain data.**
///
/// One struct, so a test can construct "Alpine with elogind and no `newuidmap`"
/// in four lines and assert the exact refusal — no VM, no container, no root. The
/// only impure function in this module is [`HostFacts::probe`].
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct HostFacts {
/// Contents of `/etc/os-release`.
pub os_release: String,
/// `/run/systemd/system` exists — the documented `sd_booted(3)` test.
pub systemd_run_system: bool,
/// `/run/systemd/seats` exists — systemd-logind is running (not merely installed).
pub systemd_seats: bool,
/// `/run/elogind` exists — elogind is running.
pub elogind_run: bool,
/// `/run/openrc/softlevel` exists — OpenRC has booted.
pub openrc_softlevel: bool,
/// `/run/runit` exists — runit is supervising.
pub runit_runsvdir: bool,
/// `/run/initctl` exists — classic sysvinit's control fifo.
pub sysvinit_initctl: bool,
/// `newuidmap` is on the host.
pub newuidmap: bool,
/// `newgidmap` is on the host.
pub newgidmap: bool,
}
impl HostFacts {
/// Read the facts off the running host. Every field is an existence test, so
/// this is cheap, needs no privileges and cannot fail — an unreadable path is
/// simply `false`, which the classifiers already treat as "not there".
pub fn probe() -> HostFacts {
HostFacts {
os_release: read_os_release(),
systemd_run_system: Path::new("/run/systemd/system").exists(),
systemd_seats: Path::new("/run/systemd/seats").exists(),
elogind_run: Path::new("/run/elogind").exists(),
openrc_softlevel: Path::new("/run/openrc/softlevel").exists(),
runit_runsvdir: Path::new("/run/runit").exists(),
sysvinit_initctl: Path::new("/run/initctl").exists(),
newuidmap: which_uidmap("newuidmap").is_some(),
newgidmap: which_uidmap("newgidmap").is_some(),
}
}
}
/// Locate `newuidmap`/`newgidmap` without a `which` subprocess and without `$PATH`
/// guesswork: the setuid helpers are installed into one of three fixed prefixes on
/// every distro in the roster, so the honest search is that list.
fn which_uidmap(tool: &str) -> Option<String> {
for dir in ["/usr/bin", "/bin", "/usr/sbin", "/sbin", "/usr/local/bin"] {
let p = format!("{dir}/{tool}");
if Path::new(&p).exists() {
return Some(p);
}
}
None
}
// ---------------------------------------------------------------------------
// The host, assembled
// ---------------------------------------------------------------------------
/// A classified host: what family it is, what init is really running, and whether
/// there is a login manager.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Host {
pub family: Family,
pub init: Init,
pub logind: Option<Logind>,
pub facts: HostFacts,
}
impl Host {
/// Classify the running host.
pub fn detect() -> Host {
Host::from_facts(HostFacts::probe())
}
/// Classify from facts (pure → fixture-testable).
pub fn from_facts(facts: HostFacts) -> Host {
Host {
family: Family::from_os_release(&facts.os_release),
init: Init::from_facts(&facts),
logind: Logind::from_facts(&facts),
facts,
}
}
/// True when the running init is not the one the family ships — the Devuan /
/// Gentoo-systemd / container-without-init case. Worth REPORTING (it explains a
/// surprising refusal) and worth never BRANCHING on.
pub fn init_is_unexpected(&self) -> bool {
self.init != self.family.expected_init() && self.init != Init::Unknown
}
/// One line for a report.
pub fn summary(&self) -> String {
let logind = match self.logind {
Some(l) => l.id(),
None => "none",
};
let mut s = format!(
"{} (init: {}, logind: {})",
self.family, self.init, logind
);
if self.init_is_unexpected() {
s.push_str(&format!(
" — NOTE: the {} family normally runs {}",
self.family,
self.family.expected_init()
));
}
s
}
}
#[cfg(test)]
mod tests {
use super::*;
fn facts(os_release: &str) -> HostFacts {
HostFacts {
os_release: os_release.to_string(),
..Default::default()
}
}
/// **`ID` and `ID_LIKE` are PARSED, not substring-matched.**
///
/// The pre-existing [`crate::platform::Platform::from_os_release`] lowercases the
/// whole file and asks `contains("debian")`, so any host whose `HOME_URL`,
/// `PRETTY_NAME` or `BUG_REPORT_URL` mentions another distro is misclassified —
/// and derivatives' os-release files mention their upstream constantly. This
/// asserts the key-parsed behaviour directly against that shape.
///
/// RED direction: replace the parse with a `contains` and the Alpine case
/// resolves to `Debian` on its `HOME_URL`.
#[test]
fn family_parses_keys_and_is_not_fooled_by_urls() {
let alpine_with_a_misleading_url = "\
NAME=\"Alpine Linux\"
ID=alpine
VERSION_ID=3.21.0
HOME_URL=\"https://alpinelinux.org/debian-comparison\"
";
assert_eq!(
Family::from_os_release(alpine_with_a_misleading_url),
Family::Alpine
);
assert_eq!(
crate::platform::Platform::from_os_release(alpine_with_a_misleading_url),
crate::platform::Platform::Debian,
"the OLD narrow type really is fooled by this — that is why a real \
parser was needed, and this asserts the difference rather than \
asserting a hope"
);
}
/// The five families the owner named, plus the three the evidence added, each
/// from a REAL `/etc/os-release` shape.
///
/// Ubuntu is the one VERIFIED against this very box (`ID=ubuntu ID_LIKE=debian`,
/// Ubuntu 26.04); the rest are the documented `ID` values of each distro.
#[test]
fn the_roster_resolves_from_real_os_release_ids() {
let cases: &[(&str, Family)] = &[
("ID=ubuntu\nID_LIKE=debian\n", Family::Debian),
("ID=debian\n", Family::Debian),
// Rocky resolves through ID_LIKE — its own ID is not in the table.
(
"ID=\"rocky\"\nID_LIKE=\"rhel centos fedora\"\n",
Family::Rhel,
),
("ID=fedora\n", Family::Rhel),
("ID=\"opensuse-tumbleweed\"\nID_LIKE=\"opensuse suse\"\n", Family::Suse),
("ID=arch\n", Family::Arch),
("ID=alpine\n", Family::Alpine),
("ID=gentoo\n", Family::Gentoo),
("ID=void\n", Family::Void),
("ID=nixos\n", Family::NixOs),
];
for (text, want) in cases {
assert_eq!(&Family::from_os_release(text), want, "for {text:?}");
}
}
/// An unmodelled distro is [`Family::Unknown`] **carrying its ID**, so the
/// refusal downstream can name what it refused. A silent fallback to Debian is
/// the failure this type exists to prevent.
#[test]
fn an_unmodelled_distro_is_unknown_and_says_which() {
let f = Family::from_os_release("ID=slackware\nPRETTY_NAME=\"Slackware 15\"\n");
assert_eq!(f, Family::Unknown("slackware".into()));
assert_eq!(f.id(), "slackware");
assert_eq!(f.package_manager(), None, "no guessed install command");
assert_eq!(f.uidmap_package(), None);
}
/// **The `newuidmap` package name really does differ per family**, and that is
/// the fact worth typing. Debian is the interesting one: `uidmap` is a separate
/// package that a default install does NOT pull in, so "podman is installed"
/// and "rootless podman works" are different statements there.
#[test]
fn each_family_names_its_own_uidmap_package() {
assert_eq!(Family::Debian.uidmap_package(), Some("uidmap"));
assert_eq!(Family::Rhel.uidmap_package(), Some("shadow-utils"));
assert_eq!(Family::Suse.uidmap_package(), Some("shadow"));
assert_eq!(Family::Arch.uidmap_package(), Some("shadow"));
assert_eq!(Family::Alpine.uidmap_package(), Some("shadow-uidmap"));
assert_eq!(
Family::Debian.package_manager().unwrap().install_command("uidmap"),
"apt-get install -y uidmap"
);
assert_eq!(
Family::Alpine.package_manager().unwrap().install_command("shadow-uidmap"),
"apk add shadow-uidmap"
);
}
/// **Family must NOT determine init.** Devuan is `ID_LIKE=debian` and runs
/// sysvinit; Gentoo ships both profiles. The classifier reads the RUNNING
/// host, and the family's expectation is reported as a note, never obeyed.
///
/// RED direction: implement `Init` as `family.expected_init()` and both
/// assertions here fail while every systemd-on-Debian test still passes — the
/// silent half.
#[test]
fn init_is_probed_from_the_host_not_derived_from_the_family() {
// Devuan: Debian packaging, sysvinit PID 1.
let devuan = Host::from_facts(HostFacts {
os_release: "ID=devuan\nID_LIKE=debian\n".into(),
sysvinit_initctl: true,
..Default::default()
});
assert_eq!(devuan.family, Family::Debian);
assert_eq!(devuan.init, Init::SysVinit, "PID 1 decides, not packaging");
assert!(devuan.init_is_unexpected());
assert!(
devuan.summary().contains("normally runs systemd"),
"the surprise must be reported: {}",
devuan.summary()
);
// Gentoo with the systemd profile: same ID, other init.
let gentoo_systemd = Host::from_facts(HostFacts {
os_release: "ID=gentoo\n".into(),
systemd_run_system: true,
systemd_seats: true,
..Default::default()
});
assert_eq!(gentoo_systemd.init, Init::Systemd);
assert_eq!(gentoo_systemd.logind, Some(Logind::Systemd));
}
/// **The Alpine case, in full — and the part everybody gets wrong.**
///
/// Stock Alpine is OpenRC with NO login manager, so lingering does not exist:
/// there is no `loginctl`, no `org.freedesktop.login1`, and nothing creates
/// `/run/user/<uid>`. But `elogind` is a real Alpine package implementing that
/// exact interface, and with it installed the SAME `SetUserLinger` call works.
/// So the honest model is "logind is a probed capability", not "Alpine cannot".
///
/// RED direction: model lingering as `family == Alpine ⇒ unsupported` and the
/// second half fails — the crate would refuse on a host that can do it.
#[test]
fn alpine_has_no_logind_by_default_but_elogind_is_a_real_answer() {
let stock = Host::from_facts(HostFacts {
os_release: "ID=alpine\n".into(),
openrc_softlevel: true,
..Default::default()
});
assert_eq!(stock.init, Init::OpenRc);
assert_eq!(stock.logind, None, "no login manager ⇒ no lingering concept");
assert!(!stock.init.has_user_manager(), "and no `systemctl --user`");
assert!(!stock.init_is_unexpected(), "OpenRC is exactly what Alpine ships");
assert_eq!(stock.family.elogind_package(), Some("elogind"));
let with_elogind = Host::from_facts(HostFacts {
os_release: "ID=alpine\n".into(),
openrc_softlevel: true,
elogind_run: true,
..Default::default()
});
assert_eq!(
with_elogind.logind,
Some(Logind::Elogind),
"elogind provides the very interface linger is set through"
);
assert_eq!(
with_elogind.logind.unwrap().linger_dir(),
"/var/lib/elogind/linger",
"…under its own state dir, not systemd's"
);
// Still OpenRC: a logind does not give the host per-user service managers.
assert!(!with_elogind.init.has_user_manager());
}
/// systemd-logind's marker directory, as VERIFIED on t14s: a zero-byte file per
/// lingering account, world-readable. That readability is load-bearing — it is
/// how the inspection path answers "already set up?" with no root and no bus.
#[test]
fn linger_state_lives_in_a_readable_marker_directory() {
assert_eq!(Logind::Systemd.linger_dir(), "/var/lib/systemd/linger");
assert_eq!(Logind::Elogind.linger_dir(), "/var/lib/elogind/linger");
}
/// NixOS is recognised specifically so it can be REFUSED: `/etc/subuid` there is
/// generated from the system configuration, and an appended line survives only
/// until the next `nixos-rebuild`.
#[test]
fn nixos_is_recognised_as_a_generated_etc() {
assert!(Family::NixOs.etc_is_generated());
for f in [
Family::Debian,
Family::Rhel,
Family::Suse,
Family::Arch,
Family::Alpine,
Family::Gentoo,
Family::Void,
] {
assert!(!f.etc_is_generated(), "{f} is a mutable /etc");
}
}
/// The bridge to the older narrow type is one-way and only where it has an
/// opinion — everything else is `Other`, exactly as before.
#[test]
fn the_platform_bridge_keeps_the_old_types_meaning() {
use crate::platform::Platform;
assert_eq!(Family::Rhel.platform(), Platform::Rhel);
assert_eq!(Family::Debian.platform(), Platform::Debian);
assert_eq!(Family::Alpine.platform(), Platform::Other);
assert_eq!(Family::Arch.platform(), Platform::Other);
}
/// The probe runs on whatever box the tests run on and must classify it without
/// panicking. Asserted loosely on purpose — this is the one test whose answer
/// depends on the host. (On t14s it VERIFIED as debian/systemd/systemd-logind.)
#[test]
fn probing_the_running_host_classifies_it() {
let h = Host::detect();
assert!(!h.summary().is_empty());
if h.init == Init::Systemd {
assert!(
h.facts.systemd_run_system,
"the systemd verdict must come from /run/systemd/system"
);
}
}
#[test]
fn empty_os_release_is_unknown_and_says_so() {
let f = Family::from_os_release("");
assert!(matches!(f, Family::Unknown(_)));
assert!(f.id().contains("no ID"));
}
/// `facts()` helper coverage: a bare os-release with no runtime markers is an
/// unknown init and no logind — the shape of a build container.
#[test]
fn a_host_with_no_init_markers_is_unknown_not_systemd() {
let h = Host::from_facts(facts("ID=debian\n"));
assert_eq!(h.init, Init::Unknown);
assert_eq!(h.logind, None);
assert!(
!h.init_is_unexpected(),
"`Unknown` is ignorance, not a contradiction — do not report it as a surprise"
);
}
}