src/rootless.rs
//! **ROOTLESS-CONTAINER HOST SETUP** — the five host facts a `User=`-confined
//! nordisk service needs before draupnir's OCI engine can talk to a *rootless*
//! podman, as an INSPECTABLE report first and a mutation second.
//!
//! # The incident this is made of (oden, 2026-08-11 — VERIFIED)
//!
//! `korp-server` is a systemd SYSTEM unit running `User=korp`: uid 111, shell
//! `/usr/sbin/nologin`, no lingering, no login session it will ever have. It needs
//! a podman API socket. Getting one took three host mutations that an operator
//! typed by hand, at a terminal, on one box:
//!
//! ```text
//! loginctl enable-linger korp # logind then creates /run/user/111
//! systemctl --user enable --now podman.socket # as korp, in ITS user manager
//! echo "korp:296608:65536" >> /etc/subuid # rootless needs a subordinate id range
//! echo "korp:296608:65536" >> /etc/subgid
//! podman system migrate # as korp
//! ```
//!
//! Results, measured: `loginctl show-user korp` → `Linger=yes`; `/run/user/111`
//! → `drwx------ korp korp`; `/run/user/111/podman/podman.sock` →
//! `srw-rw---- korp korp`; FalkorDB came up on `:6379` for the first time. Before
//! the subuid grant the image build failed with *"potentially insufficient UIDs or
//! GIDs available in user namespace (requested 111:111)"* — the error that names
//! exactly this file and is almost never read as naming it.
//!
//! Every one of those is a HOST mutation. None of them is expressible in a
//! `ServiceSpec`, which is why they were typed rather than declared, and why they
//! are typed again on the next box.
//!
//! # What this module is, and what it deliberately is not
//!
//! It is **the inspection**, first and mostly. [`inspect`] answers *"is this host
//! already set up?"* without changing anything, and [`Report::plan`] prints the
//! exact mutations that WOULD happen. [`apply`] is the small part at the end, and
//! it refuses to run at all if any step is [`State::Refused`] or
//! [`State::Unsupported`] — a half-applied host setup is worse than none.
//!
//! It is **not** a package installer. Three of the five steps can be *fixed* here;
//! the other two (`newuidmap` missing, no login manager) are host decisions whose
//! fix is a package install, i.e. a subprocess, i.e. forbidden — and, more to the
//! point, not this crate's business. They are reported with the exact command for
//! *this* family ([`crate::hostfamily::Family::uidmap_package`]) and stop the run.
//!
//! It is **not** the engine. Where the socket is, whether it answers, and whether
//! `podman system migrate` is owed belong to draupnir, which owns the OCI engine
//! and already classifies socket reachability (`draupnir::container::SocketProbe`).
//! The seam: **skidbladnir mutates the host and account; draupnir states the
//! engine's requirement and verifies the result.** Neither repeats the other.
//!
//! # Zero shell, per the LAW
//!
//! * `/etc/subuid`, `/etc/subgid`, `/etc/passwd`, `/etc/login.defs` are text files.
//! They are parsed, edited in memory and written back through a
//! same-directory temp file + `rename` — never `echo >>`, which cannot be atomic
//! and cannot preserve a mode.
//! * Lingering is a D-Bus method,
//! `org.freedesktop.login1.Manager.SetUserLinger(uid, enable, interactive)`, and
//! is called over `zbus` exactly as [`crate::systemd::Systemd`] calls
//! `org.freedesktop.systemd1` — no `loginctl` subprocess.
//! * Its *state* is read from logind's on-disk marker
//! (`/var/lib/systemd/linger/<name>`), which needs neither root nor a bus, so the
//! inspection works from an unprivileged process. VERIFIED on t14s: a zero-byte
//! file, mode 0644, in a 0755 directory.
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use crate::hostfamily::{Host, Logind};
/// Where a distribution drops UNIT FILES for user managers, in the order systemd itself
/// searches them (`/etc` wins over `/usr`, and `/lib` is the pre-usrmerge spelling of the
/// same directory). [`Step::ContainerSocket`] looks for the container engine's socket unit
/// here — it never writes one: shipping a unit is the engine package's job, and a
/// hand-written copy is a unit no package upgrade will ever fix.
const USER_UNIT_DIRS: &[&str] = &[
"/etc/systemd/user",
"/usr/lib/systemd/user",
"/lib/systemd/user",
];
/// The user-scope socket unit a rootless container engine ships. One name, because one
/// engine ships one — see [`crate::hostfamily::Family::container_engine_package`] for why
/// this is podman and not docker.
const CONTAINER_SOCKET_UNIT: &str = "podman.socket";
/// The target a socket unit installs into (`WantedBy=sockets.target` in every
/// distribution's `podman.socket`), and therefore the `.wants` directory whose symlink IS
/// the enablement — the file `systemctl --user enable` writes and the one thing that
/// survives a reboot.
const SOCKETS_TARGET_WANTS: &str = "sockets.target.wants";
/// The unit's file NAME from the path it was found at — `podman.socket` out of
/// `/usr/lib/systemd/user/podman.socket`. The report wants the path, the link wants the
/// name, and neither should be derived by hand at three call sites.
fn unit_file_name(path: &str) -> String {
Path::new(path)
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| CONTAINER_SOCKET_UNIT.to_string())
}
/// **The enablement link for `unit` in `home`'s user manager** —
/// `<home>/.config/systemd/user/sockets.target.wants/<unit>`.
///
/// ONE writer of this path, used by the reader in [`read_inputs`] and by the writer in
/// [`enable_user_unit`] alike. Spelled twice, they could disagree about whether the step is
/// done — which is the shape of bug where an install repeats work forever or skips it
/// silently.
fn user_wants_link(home: &str, unit: &str) -> PathBuf {
Path::new(home)
.join(".config/systemd/user")
.join(SOCKETS_TARGET_WANTS)
.join(unit)
}
// ---------------------------------------------------------------------------
// The account
// ---------------------------------------------------------------------------
/// A service account as `/etc/passwd` describes it.
///
/// Read from the file rather than through `getpwnam_r` FFI: the LAW keeps FFI for
/// last resort, and nothing here needs NSS to reach LDAP — a service account that
/// a *rootless container* runs as must have a local subordinate-id grant anyway,
/// and `/etc/subuid` is keyed by the name in this very file.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Account {
pub name: String,
pub uid: u32,
pub gid: u32,
/// Login shell — `nologin` for a service account, which is normal and is
/// exactly why lingering has to be granted explicitly.
pub shell: String,
pub home: String,
}
impl Account {
/// Look `name` up in `/etc/passwd` CONTENTS (pure → fixture-testable).
pub fn from_passwd(passwd: &str, name: &str) -> Option<Account> {
for line in passwd.lines() {
let f: Vec<&str> = line.split(':').collect();
if f.len() >= 7 && f[0] == name {
return Some(Account {
name: f[0].to_string(),
uid: f[2].parse().ok()?,
gid: f[3].parse().ok()?,
home: f[5].to_string(),
shell: f[6].to_string(),
});
}
}
None
}
/// The rootless runtime directory logind creates for this account.
pub fn runtime_dir(&self) -> PathBuf {
PathBuf::from(format!("/run/user/{}", self.uid))
}
/// Where a rootless podman puts its API socket for this account — the value a
/// unit's `DOCKER_HOST` must carry, and the same string draupnir derives.
pub fn podman_socket(&self) -> PathBuf {
self.runtime_dir().join("podman/podman.sock")
}
}
// ---------------------------------------------------------------------------
// Subordinate id files
// ---------------------------------------------------------------------------
/// One `owner:start:count` grant from `/etc/subuid` or `/etc/subgid`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SubIdRange {
pub owner: String,
pub start: u32,
pub count: u32,
}
impl SubIdRange {
/// Last id in the range (inclusive). `count` is a COUNT, not an end — a
/// confusion that produces off-by-65536 overlaps.
pub fn end(&self) -> u64 {
self.start as u64 + self.count as u64 - 1
}
pub fn overlaps(&self, other: &SubIdRange) -> bool {
self.start as u64 <= other.end() && other.start as u64 <= self.end()
}
pub fn render(&self) -> String {
format!("{}:{}:{}", self.owner, self.start, self.count)
}
}
/// A line of a subid file: either a parsed grant or **anything else, kept
/// verbatim**.
///
/// Comments, blank lines and malformed entries are carried through untouched
/// rather than dropped. A rewriter that silently normalises a file it does not
/// fully understand is a rewriter that eventually deletes somebody's grant.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SubIdLine {
Range(SubIdRange),
Other(String),
}
/// A parsed `/etc/subuid` or `/etc/subgid`.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct SubIdFile {
pub lines: Vec<SubIdLine>,
/// Whether the source text ended with a newline. Preserved so a rewrite of an
/// unchanged file is byte-identical.
trailing_newline: bool,
}
impl SubIdFile {
/// Parse file CONTENTS (pure).
///
/// **This must survive a file that is already wrong.** oden's `/etc/subuid`
/// contains `github:100000:65536` AND `alice:100000:65536` — two accounts
/// granted the identical range, which shadow-utils would never produce and
/// which nothing on the host complains about. The parser reads it, the
/// allocator routes around it, and neither "fixes" it: repairing somebody
/// else's grant would break whatever is currently running inside it.
pub fn parse(contents: &str) -> SubIdFile {
let mut lines = Vec::new();
for line in contents.lines() {
let t = line.trim();
let parsed = (|| {
if t.is_empty() || t.starts_with('#') {
return None;
}
let f: Vec<&str> = t.split(':').collect();
if f.len() != 3 {
return None;
}
let start: u32 = f[1].trim().parse().ok()?;
let count: u32 = f[2].trim().parse().ok()?;
if count == 0 || f[0].is_empty() {
return None;
}
Some(SubIdRange {
owner: f[0].to_string(),
start,
count,
})
})();
lines.push(match parsed {
Some(r) => SubIdLine::Range(r),
None => SubIdLine::Other(line.to_string()),
});
}
SubIdFile {
lines,
trailing_newline: contents.is_empty() || contents.ends_with('\n'),
}
}
/// Render back to text. Unchanged input round-trips byte-for-byte.
pub fn render(&self) -> String {
let mut s = String::new();
for (i, l) in self.lines.iter().enumerate() {
if i > 0 {
s.push('\n');
}
match l {
SubIdLine::Range(r) => s.push_str(&r.render()),
SubIdLine::Other(o) => s.push_str(o),
}
}
if self.trailing_newline && !s.is_empty() {
s.push('\n');
}
s
}
pub fn ranges(&self) -> impl Iterator<Item = &SubIdRange> {
self.lines.iter().filter_map(|l| match l {
SubIdLine::Range(r) => Some(r),
SubIdLine::Other(_) => None,
})
}
/// The grant for `owner`, if any. First match wins, as shadow-utils reads it.
pub fn range_for(&self, owner: &str) -> Option<&SubIdRange> {
self.ranges().find(|r| r.owner == owner)
}
/// **Overlaps that ALREADY exist in the file**, reported and never repaired.
///
/// Worth surfacing because it is invisible otherwise and it explains
/// otherwise-impossible container behaviour (two accounts writing into one
/// another's namespace ids). oden has one.
pub fn existing_overlaps(&self) -> Vec<(String, String)> {
let rs: Vec<&SubIdRange> = self.ranges().collect();
let mut out = Vec::new();
for (i, a) in rs.iter().enumerate() {
for b in rs.iter().skip(i + 1) {
if a.overlaps(b) {
out.push((a.render(), b.render()));
}
}
}
out
}
/// True if `[start, start+count)` collides with any grant in the file.
fn collides(&self, start: u32, count: u32) -> bool {
let probe = SubIdRange {
owner: String::new(),
start,
count,
};
self.ranges().any(|r| r.overlaps(&probe))
}
/// Append a grant, returning a new file. Never mutates in place, so a caller
/// can render a proposal without committing to it.
pub fn with_range(&self, r: SubIdRange) -> SubIdFile {
let mut out = self.clone();
out.lines.push(SubIdLine::Range(r));
out.trailing_newline = true;
out
}
}
/// The allocation policy — **read from the host, never invented.**
///
/// shadow-utils publishes it in `/etc/login.defs` as `SUB_UID_MIN` / `SUB_UID_MAX`
/// / `SUB_UID_COUNT` (and the `SUB_GID_*` triple). VERIFIED on t14s: `100000` /
/// `600100000` / `65536`. The fallback constants below are shadow's own compiled
/// defaults, used only when the file is absent — which is itself reported.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SubIdPolicy {
pub min: u32,
pub max: u32,
pub count: u32,
}
/// Which of the two id spaces a policy or file refers to.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IdKind {
Uid,
Gid,
}
impl IdKind {
pub fn file(self) -> &'static str {
match self {
IdKind::Uid => "/etc/subuid",
IdKind::Gid => "/etc/subgid",
}
}
fn login_defs_prefix(self) -> &'static str {
match self {
IdKind::Uid => "SUB_UID",
IdKind::Gid => "SUB_GID",
}
}
}
impl SubIdPolicy {
/// shadow-utils' compiled defaults, used only when `/etc/login.defs` is silent.
pub const SHADOW_DEFAULT: SubIdPolicy = SubIdPolicy {
min: 100_000,
max: 600_100_000,
count: 65_536,
};
/// Parse `/etc/login.defs` CONTENTS for one id space (pure).
pub fn from_login_defs(contents: &str, kind: IdKind) -> SubIdPolicy {
let p = kind.login_defs_prefix();
let get = |suffix: &str| -> Option<u32> {
let key = format!("{p}_{suffix}");
for line in contents.lines() {
let t = line.trim();
if t.starts_with('#') {
continue;
}
let mut w = t.split_whitespace();
if w.next()? == key {
return w.next()?.parse().ok();
}
}
None
};
SubIdPolicy {
min: get("MIN").unwrap_or(SubIdPolicy::SHADOW_DEFAULT.min),
max: get("MAX").unwrap_or(SubIdPolicy::SHADOW_DEFAULT.max),
count: get("COUNT").unwrap_or(SubIdPolicy::SHADOW_DEFAULT.count),
}
}
}
/// **Pick a range that overlaps nothing** — in BOTH files at once.
///
/// The algorithm is the one shadow-utils and podman visibly use, and it is
/// deliberately boring: walk the aligned slots `min`, `min+count`, `min+2*count`, …
/// and take the first that collides with no existing grant. oden's hand-typed
/// `korp:296608:65536` is `100000 + 3*65536` — the same lattice, which is how the
/// grant landed on a value nobody chose by hand.
///
/// **Uid and gid are allocated TOGETHER, to the same start.** Nothing requires it
/// mathematically, but every tool that writes these files keeps them equal, and a
/// service whose subuid and subgid bases differ produces container files whose
/// owner and group map to unrelated host ids — debuggable only by someone who
/// already knows to look here.
///
/// Returns `Err` rather than a range when the policy window is exhausted. Refusing
/// beats wrapping around into somebody's real uid space.
pub fn allocate_pair(
owner: &str,
subuid: &SubIdFile,
subgid: &SubIdFile,
policy: SubIdPolicy,
) -> Result<SubIdRange> {
let count = policy.count.max(1);
let mut start = policy.min;
loop {
if start as u64 + count as u64 - 1 > policy.max as u64 {
anyhow::bail!(
"no free subordinate-id range for `{owner}`: every aligned slot of {count} \
between {} and {} is already granted in {} or {}. Widen SUB_UID_MAX/\
SUB_GID_MAX in /etc/login.defs, or reclaim a range — this will not \
allocate outside the host's own policy window.",
policy.min,
policy.max,
IdKind::Uid.file(),
IdKind::Gid.file(),
);
}
if !subuid.collides(start, count) && !subgid.collides(start, count) {
return Ok(SubIdRange {
owner: owner.to_string(),
start,
count,
});
}
start = match start.checked_add(count) {
Some(s) => s,
None => anyhow::bail!("subordinate-id search overflowed u32 for `{owner}`"),
};
}
}
// ---------------------------------------------------------------------------
// Steps, states, findings
// ---------------------------------------------------------------------------
/// One host fact a rootless service account needs.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Step {
/// `newuidmap`/`newgidmap` exist. Without them a rootless container gets ONE
/// uid — its own — and any image with a second owner fails to unpack.
IdMapTools,
/// A `/etc/subuid` grant for the account.
SubUid,
/// A `/etc/subgid` grant for the account.
SubGid,
/// Lingering, so the login manager keeps a user manager and a runtime dir for
/// an account that will never log in.
Linger,
/// `/run/user/<uid>` exists and belongs to the account.
RuntimeDir,
/// The account's own container-engine socket unit is ENABLED in its user manager,
/// so it exists because it was declared rather than because something asked for it
/// at the right moment.
///
/// This step is the difference between a host that works and a host that KEEPS
/// working. MEASURED on t14s 2026-08-15, after the five steps above were all
/// satisfied: `podman.socket` in the `korp` account's user manager was `active` but
/// `disabled` — an on-demand start by a client that happened to ask (knut's
/// `ensure_podman_socket`, which can only ask once lingering has given the account a
/// session bus). Nothing had enabled it, so it would not have come back on the next
/// boot, and a service that starts containers at boot would have been racing a client
/// that starts sockets on demand.
///
/// That is the same defect this crate exists to remove, one level down: a service
/// that only works because of a command nobody's installer ran.
ContainerSocket,
}
impl Step {
pub fn id(self) -> &'static str {
match self {
Step::IdMapTools => "idmap-tools",
Step::SubUid => "subuid",
Step::SubGid => "subgid",
Step::Linger => "linger",
Step::RuntimeDir => "runtime-dir",
Step::ContainerSocket => "container-socket",
}
}
}
/// A mutation this crate is willing to perform. Data, so it can be PRINTED by a
/// dry run and executed by [`apply`] from the same value — the plan and the doing
/// cannot drift.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Change {
/// Append `owner:start:count` to a subid file.
GrantSubId {
file: PathBuf,
range: SubIdRange,
},
/// `org.freedesktop.login1.Manager.SetUserLinger(uid, true, false)`.
EnableLinger {
name: String,
uid: u32,
logind: Logind,
},
/// Enable a unit in the ACCOUNT'S OWN user manager, by writing the `.wants` symlink
/// that IS the enablement — `<home>/.config/systemd/user/sockets.target.wants/<unit>`
/// → the packaged unit file, owned by the account.
///
/// **Why a symlink and not `EnableUnitFiles` over D-Bus.** Both doors are shut for a
/// root caller, MEASURED on t14s 2026-08-15:
///
/// * the SESSION bus (`/run/user/<uid>/bus`) is mode 0666 but its policy admits one
/// uid, so root is refused during EXTERNAL authentication — "failed to read from
/// socket";
/// * the user manager's PRIVATE socket (`/run/user/<uid>/systemd/private`) does
/// admit root, and zbus cannot speak it: sd-bus's direct protocol carries a sender
/// field that is not a valid D-Bus unique name, so zbus 5.19 PANICS its connection
/// executor (`message/fields.rs:219`, `InvalidName`) while parsing the reply. The
/// calling thread then waits on a futex forever — an install sat there 11 minutes
/// with 2 seconds of CPU, its work already done and its reply unparseable.
///
/// So the D-Bus route is not "less elegant", it is unavailable, and shelling
/// `systemctl` is not an option this crate has. The symlink is not a workaround for
/// the API — it is what the API writes: `systemctl --user is-enabled` answers by
/// reading exactly this link, which is why enabling this way is indistinguishable from
/// enabling that way.
///
/// Ordered after [`Change::EnableLinger`], and still load-bearing even without a bus:
/// the account's manager must exist for the enablement to mean anything at boot.
EnableUserSocket {
name: String,
uid: u32,
gid: u32,
/// The packaged unit file the link points AT (`/usr/lib/systemd/user/podman.socket`).
unit_path: String,
/// The link itself — `<home>/.config/systemd/user/sockets.target.wants/<unit>`.
link: PathBuf,
},
}
impl Change {
/// The change in one line, for a dry run.
pub fn describe(&self) -> String {
match self {
Change::GrantSubId { file, range } => format!(
"append `{}` to {} (atomic temp-file + rename, mode preserved)",
range.render(),
file.display()
),
Change::EnableLinger { name, uid, logind } => format!(
"SetUserLinger({uid}, true) on {} — the D-Bus call `loginctl enable-linger {name}` makes",
logind.id()
),
Change::EnableUserSocket {
name,
uid,
unit_path,
link,
..
} => format!(
"symlink {} -> {unit_path}, owned by `{name}` (uid {uid}) — the file \
`systemctl --user enable` writes, and the one `is-enabled` reads back",
link.display()
),
}
}
}
/// What [`inspect`] found for one [`Step`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum State {
/// Already correct. Idempotency lives here: [`apply`] does nothing for these.
Satisfied(String),
/// Not correct, and this crate can fix it.
Needed { detail: String, change: Change },
/// Will become true as a CONSEQUENCE of another step — nothing to do directly.
/// `/run/user/<uid>` is the only one: logind creates it when linger is set, and
/// a tool that `mkdir`s it itself produces a directory logind does not own and
/// will not populate.
Follows(String),
/// This host genuinely cannot do it. Not a no-op, not a soft failure — the
/// mechanism does not exist here.
Unsupported(String),
/// The fix is a HOST decision (install a package), stated with the exact
/// command for this family and NOT performed.
Manual(String),
/// Refuse: proceeding would be a guess, or would be silently undone.
Refused(String),
}
impl State {
/// Does this state stop [`apply`] from running at all?
pub fn is_blocking(&self) -> bool {
matches!(
self,
State::Unsupported(_) | State::Manual(_) | State::Refused(_)
)
}
pub fn detail(&self) -> &str {
match self {
State::Satisfied(s)
| State::Follows(s)
| State::Unsupported(s)
| State::Manual(s)
| State::Refused(s) => s,
State::Needed { detail, .. } => detail,
}
}
pub fn label(&self) -> &'static str {
match self {
State::Satisfied(_) => "OK",
State::Needed { .. } => "NEEDED",
State::Follows(_) => "FOLLOWS",
State::Unsupported(_) => "UNSUPPORTED",
State::Manual(_) => "MANUAL",
State::Refused(_) => "REFUSED",
}
}
}
/// One step and what was found.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Finding {
pub step: Step,
pub state: State,
}
/// **Everything [`assess`] reads** — as data, so the whole decision matrix is
/// testable against fixtures with no host, no root and no VM.
///
/// `matrix.rs` is where booting a real Alpine and a real Ubuntu would prove the
/// READING of these values; the BEHAVIOUR they drive is pure and belongs here.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Inputs {
pub host: Host,
pub account_name: String,
/// Contents of `/etc/passwd`.
pub passwd: String,
/// Contents of `/etc/subuid`; `None` when the file could not be read.
pub subuid: Option<String>,
/// Contents of `/etc/subgid`; `None` when the file could not be read.
pub subgid: Option<String>,
/// Contents of `/etc/login.defs` (empty ⇒ shadow's compiled defaults).
pub login_defs: String,
/// logind's linger marker for this account exists.
pub linger_marker: bool,
/// Owner uid of `/run/user/<uid>`, or `None` when the directory is absent.
pub runtime_dir_owner: Option<u32>,
/// The container-engine socket unit this host ships for USER managers, as the path it
/// was found at (`/usr/lib/systemd/user/podman.socket` and friends). `None` ⇒ the host
/// has no such unit, which is a statement about the host, not about the account.
pub container_socket_unit: Option<String>,
/// The account has ENABLED that unit — the `sockets.target.wants/<unit>` symlink under
/// its own `~/.config/systemd/user`, which is what `systemctl --user enable` writes and
/// what makes the socket come back after a reboot.
pub container_socket_enabled: bool,
}
/// The result of an inspection.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Report {
pub host: Host,
pub account_name: String,
/// `None` when the account has no `/etc/passwd` entry — a hard refusal.
pub account: Option<Account>,
pub findings: Vec<Finding>,
/// Overlaps that already existed in `/etc/subuid` — reported, never repaired.
pub existing_subuid_overlaps: Vec<(String, String)>,
}
impl Report {
/// Is the host already set up? True only when every step is
/// [`State::Satisfied`] — `Follows` is not ready, it is pending.
pub fn is_ready(&self) -> bool {
!self.findings.is_empty()
&& self
.findings
.iter()
.all(|f| matches!(f.state, State::Satisfied(_)))
}
/// Findings that stop [`apply`].
pub fn blockers(&self) -> Vec<&Finding> {
self.findings
.iter()
.filter(|f| f.state.is_blocking())
.collect()
}
/// The mutations [`apply`] would perform, in order. **This is the dry run**, and
/// it is worth more than the doing: an empty plan on a blocked host is not
/// "nothing to do", so read [`Report::blockers`] as well.
pub fn plan(&self) -> Vec<Change> {
self.findings
.iter()
.filter_map(|f| match &f.state {
State::Needed { change, .. } => Some(change.clone()),
_ => None,
})
.collect()
}
/// The whole report as text, one line per step — the crisp column form.
pub fn render(&self) -> String {
let mut s = format!(
"rootless host setup for `{}` on {}\n",
self.account_name,
self.host.summary()
);
match &self.account {
Some(a) => s.push_str(&format!(
" account uid={} gid={} shell={} runtime-dir={}\n",
a.uid,
a.gid,
a.shell,
a.runtime_dir().display()
)),
None => s.push_str(" account ABSENT from /etc/passwd\n"),
}
for f in &self.findings {
s.push_str(&format!(
" {:<16} {:<12} {}\n",
f.step.id(),
f.state.label(),
f.state.detail()
));
}
for (a, b) in &self.existing_subuid_overlaps {
s.push_str(&format!(
" NOTE /etc/subuid already contains overlapping grants \
`{a}` and `{b}` — left exactly as found; this tool allocates around \
them and repairs nothing it did not write\n"
));
}
s
}
}
/// **The whole decision, as a pure function of [`Inputs`].**
///
/// No filesystem, no bus, no privileges. Every refusal, every "unsupported", every
/// proposed range is decided here, which is why the distro-family behaviour is
/// unit-testable without four VMs.
pub fn assess(inp: &Inputs) -> Report {
let account = Account::from_passwd(&inp.passwd, &inp.account_name);
let subuid_file = inp.subuid.as_deref().map(SubIdFile::parse);
let subgid_file = inp.subgid.as_deref().map(SubIdFile::parse);
let overlaps = subuid_file
.as_ref()
.map(|f| f.existing_overlaps())
.unwrap_or_default();
let mut findings = Vec::new();
// ── 1. the setuid id-mapping helpers ────────────────────────────────────
//
// First because it is the step whose absence produces the most misleading
// error: podman reports "potentially insufficient UIDs or GIDs available in
// user namespace (requested 111:111)", which reads like a subuid problem even
// when the grant is present and the helper is what is missing.
findings.push(Finding {
step: Step::IdMapTools,
state: if inp.host.facts.newuidmap && inp.host.facts.newgidmap {
State::Satisfied(
"newuidmap and newgidmap are installed (this does NOT verify they are \
setuid-root or carry file capabilities — reading xattrs is outside \
std, and a helper without either still fails at run time)"
.into(),
)
} else {
let missing = match (inp.host.facts.newuidmap, inp.host.facts.newgidmap) {
(false, false) => "newuidmap and newgidmap are",
(false, true) => "newuidmap is",
_ => "newgidmap is",
};
match (
inp.host.family.uidmap_package(),
inp.host.family.package_manager(),
) {
(Some(pkg), Some(pm)) => State::Manual(format!(
"{missing} absent. On the {} family they come from `{pkg}`: {}. \
Installing a package is a host decision and a subprocess — this \
crate will not do it.",
inp.host.family,
pm.install_command(pkg)
)),
_ => State::Refused(format!(
"{missing} absent, and the distro family is `{}` — this crate does \
not know which package supplies them here, and will not guess a \
package name.",
inp.host.family
)),
}
},
});
// ── 2 & 3. the subordinate id grants ────────────────────────────────────
let policy_uid = SubIdPolicy::from_login_defs(&inp.login_defs, IdKind::Uid);
let policy_gid = SubIdPolicy::from_login_defs(&inp.login_defs, IdKind::Gid);
// The pair must land on one start (see `allocate_pair`), so the narrower of the
// two policies governs: a range legal for uids and illegal for gids is not a
// range this will hand out.
let policy = SubIdPolicy {
min: policy_uid.min.max(policy_gid.min),
max: policy_uid.max.min(policy_gid.max),
count: policy_uid.count.min(policy_gid.count),
};
let proposed = match (&subuid_file, &subgid_file, &account) {
(Some(u), Some(g), Some(_)) => Some(allocate_pair(&inp.account_name, u, g, policy)),
_ => None,
};
for (step, kind, file) in [
(Step::SubUid, IdKind::Uid, &subuid_file),
(Step::SubGid, IdKind::Gid, &subgid_file),
] {
let state = match (file, &account) {
(_, None) => State::Refused(format!(
"account `{}` has no /etc/passwd entry — there is nothing to grant a \
subordinate id range TO, and creating the account is not this crate's \
decision.",
inp.account_name
)),
(None, _) => State::Refused(format!(
"{} could not be read. It is not optional: without it rootless podman \
has exactly one id to map. Refusing rather than creating a file whose \
intended ownership and mode are unknown.",
kind.file()
)),
(Some(f), Some(_)) => match f.range_for(&inp.account_name) {
Some(r) => State::Satisfied(format!(
"{} already grants `{}` ({} ids from {})",
kind.file(),
r.render(),
r.count,
r.start
)),
None => match &proposed {
Some(Ok(r)) => State::Needed {
detail: format!(
"no grant for `{}` in {}",
inp.account_name,
kind.file()
),
change: Change::GrantSubId {
file: PathBuf::from(kind.file()),
range: r.clone(),
},
},
Some(Err(e)) => State::Refused(format!("{e:#}")),
None => State::Refused(format!(
"cannot allocate a range for {} without reading both \
/etc/subuid and /etc/subgid — the pair is granted together.",
kind.file()
)),
},
},
};
// NixOS overrides everything above: the file is generated, so a correct
// append is a change that disappears at the next rebuild.
let state = if inp.host.family.etc_is_generated() && !matches!(state, State::Satisfied(_)) {
State::Refused(format!(
"{} on {} is GENERATED from the system configuration. An appended line \
survives until the next `nixos-rebuild` and then vanishes, taking every \
container that depended on it. Declare it instead — \
`users.users.{}.subUidRanges` / `autoSubUidGidRange` — and rebuild.",
kind.file(),
inp.host.family,
inp.account_name
))
} else {
state
};
findings.push(Finding { step, state });
}
// ── 4. lingering ────────────────────────────────────────────────────────
//
// The step that is NOT portable, and the reason a family type had to exist.
// Lingering is a login-manager concept: `SetUserLinger` on
// `org.freedesktop.login1`. On a host with no login manager there is no
// `loginctl`, no such bus name and no such operation — and saying so is the
// deliverable. A "portable" interface that no-ops here would hand back a
// successful setup and a service that cannot start.
let linger_state = match (&account, inp.host.logind) {
(None, _) => State::Refused(format!(
"account `{}` has no /etc/passwd entry — SetUserLinger takes a uid and \
there is none.",
inp.account_name
)),
(Some(_), None) => {
let mut m = format!(
"this host runs {} with NO login manager, so lingering does not exist \
here: there is no `loginctl`, no org.freedesktop.login1, and nothing \
will create /run/user/<uid> for an account with no session.",
inp.host.init
);
match inp.host.family.elogind_package() {
Some(pkg) => m.push_str(&format!(
" `{pkg}` provides exactly that interface on the {} family and the \
same D-Bus call then works — installing it is a host decision. \
Otherwise a rootless engine on this host needs its runtime dir \
provided another way (an explicit XDG_RUNTIME_DIR the service \
itself owns), which is a different design, not this one.",
inp.host.family
)),
None => m.push_str(
" No packaged login manager is known for this family — a rootless \
engine here needs a different runtime-dir design.",
),
}
State::Unsupported(m)
}
(Some(a), Some(l)) => {
if inp.linger_marker {
State::Satisfied(format!(
"{}/{} exists — {} keeps a user manager and a runtime dir for this \
account with no session",
l.linger_dir(),
a.name,
l.id()
))
} else {
State::Needed {
detail: format!(
"`{}` (uid {}, shell {}) does not linger, so {} tears its \
runtime dir down the moment nothing holds it — and a nologin \
service account never holds it in the first place",
a.name,
a.uid,
a.shell,
l.id()
),
change: Change::EnableLinger {
name: a.name.clone(),
uid: a.uid,
logind: l,
},
}
}
}
};
findings.push(Finding {
step: Step::Linger,
state: linger_state,
});
// ── 5. the runtime directory ────────────────────────────────────────────
//
// Never `Needed`: this crate does not create it. logind does, on linger, with
// ownership and a tmpfs mount a `mkdir` cannot reproduce — a hand-made
// /run/user/<uid> is a directory logind neither owns nor populates, and the
// podman socket never appears inside it.
let runtime_state = match (&account, inp.runtime_dir_owner, inp.host.logind) {
(None, _, _) => State::Refused("no account, so no runtime dir to check".into()),
(Some(a), Some(owner), _) if owner == a.uid => State::Satisfied(format!(
"{} exists and belongs to uid {} — a rootless podman socket lands at {}",
a.runtime_dir().display(),
a.uid,
a.podman_socket().display()
)),
(Some(a), Some(owner), _) => State::Refused(format!(
"{} exists but belongs to uid {owner}, not {} — refusing to touch another \
account's runtime directory.",
a.runtime_dir().display(),
a.uid
)),
(Some(a), None, Some(l)) => State::Follows(format!(
"{} does not exist yet; {} creates it when lingering is enabled. This crate \
does not mkdir it — a hand-made runtime dir is one logind does not own and \
will not populate.",
a.runtime_dir().display(),
l.id()
)),
(Some(a), None, None) => State::Unsupported(format!(
"{} does not exist and there is no login manager to create it.",
a.runtime_dir().display()
)),
};
findings.push(Finding {
step: Step::RuntimeDir,
state: runtime_state,
});
// ── 6. the account's OWN container-engine socket, ENABLED ───────────────
//
// The five steps above make a rootless container POSSIBLE. This one makes the
// socket a service can connect to actually BE THERE — at boot, on every boot,
// because the account declared it rather than because a client asked at the
// right moment.
//
// The failure it removes is not a missing capability but a race, and races are
// the defects that survive a working demo: on t14s the socket was `active` and
// `disabled` at the same time, so everything worked until the next reboot.
//
// `Needed` only when the host actually ships a user socket unit. Where it does
// not, this is `Manual` rather than `Refused`: installing a container engine is a
// host decision, exactly as `IdMapTools` treats the id-mapping helpers, and the
// wording matches so the two read the same way in one report.
let socket_state = match (&account, &inp.container_socket_unit) {
(None, _) => State::Refused("no account, so no user manager to enable a socket in".into()),
(Some(a), Some(unit_path)) => {
let unit = unit_file_name(unit_path);
if inp.container_socket_enabled {
State::Satisfied(format!(
"`{unit}` is enabled in `{}`'s user manager — logind starts it with the \
account's manager, so {} exists at boot rather than when a client \
happens to ask",
a.name,
a.podman_socket().display()
))
} else {
State::Needed {
detail: format!(
"the host ships `{unit}` ({unit_path}) but `{}` has not enabled it, \
so {} exists only while some client keeps starting it on demand — \
and after a reboot a service that starts containers is racing a \
client that starts sockets",
a.name,
a.podman_socket().display()
),
change: Change::EnableUserSocket {
name: a.name.clone(),
uid: a.uid,
gid: a.gid,
unit_path: unit_path.clone(),
// The same path `read_inputs` reads enablement from, built from the
// same three pieces — one spelling of "where the link lives", or the
// reader and the writer can disagree about whether it is done.
link: user_wants_link(&a.home, &unit),
},
}
}
}
(Some(_), None) => match (
inp.host.family.container_engine_package(),
inp.host.family.package_manager(),
) {
(Some(pkg), Some(pm)) => State::Manual(format!(
"this host ships no user-scope container socket unit (looked in {}). On the \
{} family it comes from `{pkg}`: {}. Installing a package is a host \
decision and a subprocess — this crate will not do it.",
USER_UNIT_DIRS.join(", "),
inp.host.family,
pm.install_command(pkg)
)),
_ => State::Manual(format!(
"this host ships no user-scope container socket unit (looked in {}), and \
the distro family is `{}` — this crate does not know which package \
supplies one here and will not guess a package name. A service that runs \
containers as this account needs one, or a DOCKER_HOST pointing at an \
engine it may open.",
USER_UNIT_DIRS.join(", "),
inp.host.family
)),
},
};
findings.push(Finding {
step: Step::ContainerSocket,
state: socket_state,
});
Report {
host: inp.host.clone(),
account_name: inp.account_name.clone(),
account,
findings,
existing_subuid_overlaps: overlaps,
}
}
// ---------------------------------------------------------------------------
// Reading the real host
// ---------------------------------------------------------------------------
/// Read every input off the running host. Read-only; no privileges needed for the
/// parts that matter (see the module note on logind's readable marker).
pub fn read_inputs(account: &str) -> Inputs {
let host = Host::detect();
let passwd = std::fs::read_to_string("/etc/passwd").unwrap_or_default();
let acct = Account::from_passwd(&passwd, account);
let linger_marker = host
.logind
.map(|l| Path::new(l.linger_dir()).join(account).exists())
.unwrap_or(false);
let runtime_dir_owner = acct.as_ref().and_then(|a| {
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
std::fs::metadata(a.runtime_dir()).ok().map(|m| m.uid())
}
#[cfg(not(unix))]
{
let _ = a;
None
}
});
// The engine's user socket unit, as the host ships it. Searched in systemd's own
// order and read as a PATH rather than a yes/no, so the report can name the file it
// found instead of asserting that one exists somewhere.
let container_socket_unit = USER_UNIT_DIRS
.iter()
.map(|d| Path::new(d).join(CONTAINER_SOCKET_UNIT))
.find(|p| p.exists())
.map(|p| p.to_string_lossy().into_owned());
// Enablement is a SYMLINK in the account's own config, which is what makes this
// readable with no bus, no root and no running user manager — the same posture as the
// linger marker above. Asking systemd would mean talking to a manager that may not be
// running yet, and getting "inactive" for an account that is merely not lingering.
let container_socket_enabled = acct
.as_ref()
.map(|a| {
// `symlink_metadata`, not `exists()`: a dangling link is still an enablement
// systemd will act on (and complain about), and `exists()` follows the link, so
// it would call a broken enablement "not done" and write a second one.
std::fs::symlink_metadata(user_wants_link(&a.home, CONTAINER_SOCKET_UNIT)).is_ok()
})
.unwrap_or(false);
Inputs {
host,
account_name: account.to_string(),
passwd,
subuid: std::fs::read_to_string(IdKind::Uid.file()).ok(),
subgid: std::fs::read_to_string(IdKind::Gid.file()).ok(),
login_defs: std::fs::read_to_string("/etc/login.defs").unwrap_or_default(),
linger_marker,
runtime_dir_owner,
container_socket_unit,
container_socket_enabled,
}
}
/// **"Is this host already set up?"** — the read-only answer, which must be
/// available before anyone asks for the mutation.
pub fn inspect(account: &str) -> Report {
assess(&read_inputs(account))
}
// ---------------------------------------------------------------------------
// Applying
// ---------------------------------------------------------------------------
/// What [`apply`] did.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Applied {
/// One line per mutation actually performed. Empty on an already-set-up host,
/// which is the idempotent case and is a success.
pub performed: Vec<String>,
}
/// **Perform the plan.** Refuses entirely if anything is blocking.
///
/// The refusal is the point. A host missing `newuidmap` can still have its subuid
/// grant written and its linger enabled — and would then look configured while
/// still being unable to run a container. Half-applied is worse than none, so a
/// single [`State::Unsupported`] / [`State::Manual`] / [`State::Refused`] stops the
/// whole run and returns every blocker at once, rather than the first.
///
/// Idempotent: it acts only on [`State::Needed`], so a second call on a set-up host
/// performs nothing and succeeds.
///
/// Needs root — writing `/etc/subuid` and calling `SetUserLinger` both do — and
/// says which operation was refused rather than "permission denied".
pub fn apply(report: &Report) -> Result<Applied> {
let blockers = report.blockers();
if !blockers.is_empty() {
let mut m = format!(
"refusing to set up rootless containers for `{}` — {} step(s) cannot be \
completed, and a half-applied host setup is worse than none:",
report.account_name,
blockers.len()
);
for b in blockers {
m.push_str(&format!(
"\n [{}] {}: {}",
b.state.label(),
b.step.id(),
b.state.detail()
));
}
anyhow::bail!(m);
}
let mut applied = Applied::default();
for change in report.plan() {
match &change {
Change::GrantSubId { file, range } => {
let current = std::fs::read_to_string(file)
.with_context(|| format!("read {} before granting", file.display()))?;
let parsed = SubIdFile::parse(¤t);
// Re-check under the file we just read: between `inspect` and here
// another writer (usermod, podman, a second install) may have
// granted the same account. Idempotency has to hold against THAT
// too, not just against our own earlier run.
if let Some(existing) = parsed.range_for(&range.owner) {
applied.performed.push(format!(
"{}: `{}` already granted by another writer since inspection — \
left alone",
file.display(),
existing.render()
));
continue;
}
let next = parsed.with_range(range.clone());
write_atomically(file, &next.render())?;
applied
.performed
.push(format!("{}: granted `{}`", file.display(), range.render()));
}
Change::EnableLinger { name, uid, logind } => {
set_linger(*uid, true).with_context(|| {
format!("enable lingering for `{name}` (uid {uid}) on {}", logind.id())
})?;
applied.performed.push(format!(
"{}: lingering enabled for `{name}` (uid {uid}) — {} will now create {}",
logind.id(),
logind.id(),
format_args!("/run/user/{uid}")
));
}
Change::EnableUserSocket {
name,
uid,
gid,
unit_path,
link,
} => {
enable_user_unit(link, unit_path, *uid, *gid).with_context(|| {
format!(
"enable `{}` in `{name}`'s user manager (uid {uid})",
unit_file_name(unit_path)
)
})?;
applied.performed.push(format!(
"`{name}` (uid {uid}): {} -> {unit_path} — `{}` now comes up with the \
account's own manager at boot, instead of only when a client asks",
link.display(),
unit_file_name(unit_path)
));
}
}
}
Ok(applied)
}
/// Convenience: inspect the running host, then apply. Returns the report as well,
/// so a caller can print what was found even when the apply refused.
pub fn ensure(account: &str) -> (Report, Result<Applied>) {
let report = inspect(account);
let outcome = apply(&report);
(report, outcome)
}
/// Write `contents` to `path` through a same-directory temp file + `rename`,
/// preserving the original mode.
///
/// `>>` cannot do this. An append that is interrupted leaves a truncated grant
/// line, and `/etc/subuid` with a half-written line is a file shadow-utils
/// silently ignores from that line on. Same directory so the rename is a rename
/// and not a cross-device copy.
fn write_atomically(path: &Path, contents: &str) -> Result<()> {
let dir = path.parent().unwrap_or(Path::new("."));
let tmp = dir.join(format!(
".{}.skidbladnir.tmp",
path.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "subid".into())
));
std::fs::write(&tmp, contents)
.with_context(|| format!("write staging file {}", tmp.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
// Mirror the existing file's mode; fall back to shadow's own 0644 for these
// files (they are world-readable by design — every container runtime reads
// them unprivileged).
let mode = std::fs::metadata(path)
.map(|m| m.permissions().mode() & 0o7777)
.unwrap_or(0o644);
let _ = std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(mode));
}
std::fs::rename(&tmp, path).with_context(|| {
format!(
"rename {} onto {} (the atomic step)",
tmp.display(),
path.display()
)
})?;
Ok(())
}
/// **Set lingering over D-Bus** — `org.freedesktop.login1.Manager.SetUserLinger`.
///
/// The pure-Rust equivalent of `loginctl enable-linger <user>`, and the same shape
/// [`crate::systemd::Systemd`] already uses for `org.freedesktop.systemd1`: a
/// blocking `zbus` call on the SYSTEM bus, no `libdbus`, no subprocess.
///
/// `interactive = false` deliberately: a polkit prompt has nowhere to go from an
/// installer, so this either has the privilege or fails saying it does not.
///
/// Both logind implementations own this bus name — systemd-logind and elogind —
/// so one call covers both and there is no branch here on which is running.
#[cfg(unix)]
pub fn set_linger(uid: u32, enable: bool) -> Result<()> {
let conn = zbus::block_on(zbus::Connection::system()).map_err(|e| {
anyhow::anyhow!(
"connect the system bus to reach org.freedesktop.login1: {e}. Lingering is a \
login-manager operation; without a bus there is no `loginctl` equivalent to \
fall back to, and this crate does not shell out."
)
})?;
zbus::block_on(conn.call_method(
Some("org.freedesktop.login1"),
"/org/freedesktop/login1",
Some("org.freedesktop.login1.Manager"),
"SetUserLinger",
// (uid: u, enable: b, interactive: b)
&(uid, enable, false),
))
.map_err(|e| {
anyhow::anyhow!("SetUserLinger({uid}, {enable}) on org.freedesktop.login1: {e}")
})?;
Ok(())
}
#[cfg(not(unix))]
pub fn set_linger(_uid: u32, _enable: bool) -> Result<()> {
anyhow::bail!("lingering is a Linux login-manager concept; there is nothing to set here")
}
/// **Enable a unit in another account's user manager, by writing the link that IS the
/// enablement** — `<home>/.config/systemd/user/sockets.target.wants/<unit>` → the packaged
/// unit file, with every directory it had to create, and the link itself, owned by that
/// account.
///
/// `systemctl --user enable` writes this symlink; `systemctl --user is-enabled` answers by
/// reading it. Doing it here is therefore not an imitation of the API — it is the API's
/// effect, reached the one way a root caller can (see [`Change::EnableUserSocket`] for the
/// two D-Bus doors and why both are shut).
///
/// **Ownership is not cosmetic.** A root-owned `~/.config/systemd` is a tree the account
/// cannot later manage: the user's own `systemctl --user disable` fails on it, and the
/// account is left unable to undo what an installer did to it. So every component created
/// here is chowned, and the link is `lchown`ed rather than `chown`ed — chowning a symlink
/// FOLLOWS it and would re-own `/usr/lib/systemd/user/podman.socket`, a packaged system
/// file, which is exactly the accident this paragraph exists to prevent.
///
/// Idempotent: a link already pointing at `unit_path` is left alone; one pointing elsewhere
/// is replaced, because an install asserts a desired state rather than merging with
/// whatever was there.
///
/// **It does not start the unit** — the name says `enable`, not `enable --now`, for that
/// reason. Starting needs the manager's bus, which is the unreachable half; the socket is
/// started on demand by the engine's clients in the meantime, and by the account's own
/// manager at every boot after, which is the durable property this step was added for.
#[cfg(unix)]
pub fn enable_user_unit(link: &Path, unit_path: &str, uid: u32, gid: u32) -> Result<()> {
use std::os::unix::fs::{lchown, symlink};
if !Path::new(unit_path).exists() {
anyhow::bail!(
"{unit_path} does not exist — refusing to write an enablement link to a unit \
file that is not there. A dangling `.wants` link is a unit systemd complains \
about at every boot."
);
}
// Created component by component so each NEW directory can be handed to the account.
// `create_dir_all` would leave them all root-owned, and a root-owned `~/.config` is a
// lasting handicap for an account that must manage its own units.
let parent = link
.parent()
.ok_or_else(|| anyhow::anyhow!("{} has no parent directory", link.display()))?;
let mut built = PathBuf::new();
for comp in parent.components() {
built.push(comp);
if built.exists() {
continue;
}
std::fs::create_dir(&built).with_context(|| format!("create {}", built.display()))?;
std::os::unix::fs::chown(&built, Some(uid), Some(gid))
.with_context(|| format!("hand {} to uid {uid}", built.display()))?;
}
// Idempotency read off the LINK, without following it.
match std::fs::read_link(link) {
Ok(target) if target == Path::new(unit_path) => return Ok(()),
Ok(_) => std::fs::remove_file(link)
.with_context(|| format!("replace the existing link {}", link.display()))?,
// Not a link, or absent. A regular file here is somebody's own copy of a unit;
// deleting that is not this crate's call, so let `symlink` fail and say so.
Err(_) => {}
}
symlink(unit_path, link).with_context(|| format!("link {} -> {unit_path}", link.display()))?;
lchown(link, Some(uid), Some(gid))
.with_context(|| format!("hand the link {} to uid {uid}", link.display()))?;
Ok(())
}
#[cfg(not(unix))]
pub fn enable_user_unit(_link: &Path, _unit_path: &str, _uid: u32, _gid: u32) -> Result<()> {
anyhow::bail!("user managers are a Linux systemd concept; there is no unit to enable here")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::hostfamily::HostFacts;
const ODEN_PASSWD: &str = "\
root:x:0:0:root:/root:/bin/bash
korp:x:111:111::/home/korp:/usr/sbin/nologin
alice:x:1000:1000::/home/alice:/bin/bash
";
/// A stock Ubuntu-shaped host with the id-map helpers present.
fn ubuntu_facts() -> HostFacts {
HostFacts {
os_release: "ID=ubuntu\nID_LIKE=debian\nVERSION_ID=\"24.04\"\n".into(),
systemd_run_system: true,
systemd_seats: true,
newuidmap: true,
newgidmap: true,
..Default::default()
}
}
fn inputs(host: Host) -> Inputs {
Inputs {
host,
account_name: "korp".into(),
passwd: ODEN_PASSWD.into(),
subuid: Some("alice:100000:65536\n".into()),
subgid: Some("alice:100000:65536\n".into()),
login_defs: "SUB_UID_MIN 100000\nSUB_UID_MAX 600100000\nSUB_UID_COUNT 65536\n\
SUB_GID_MIN 100000\nSUB_GID_MAX 600100000\nSUB_GID_COUNT 65536\n"
.into(),
linger_marker: false,
runtime_dir_owner: None,
// The shape a real host has BEFORE anything ran: the engine's unit is
// installed (podman is a package, not something this crate provides) and the
// service account has not enabled it — which is exactly the t14s state that
// made `Step::ContainerSocket` necessary.
container_socket_unit: Some("/usr/lib/systemd/user/podman.socket".into()),
container_socket_enabled: false,
}
}
fn finding(r: &Report, step: Step) -> &Finding {
r.findings.iter().find(|f| f.step == step).expect("step")
}
// ── /etc/passwd ────────────────────────────────────────────────────────
/// The account read is the one oden actually has: uid 111, `nologin`. That
/// shell is not an anomaly to work around — it is the normal shape of a service
/// account and the entire reason lingering must be granted explicitly.
#[test]
fn the_service_account_is_read_from_passwd_including_its_nologin_shell() {
let a = Account::from_passwd(ODEN_PASSWD, "korp").expect("korp is in passwd");
assert_eq!(a.uid, 111);
assert_eq!(a.gid, 111);
assert_eq!(a.shell, "/usr/sbin/nologin");
assert_eq!(a.runtime_dir(), Path::new("/run/user/111"));
assert_eq!(
a.podman_socket(),
Path::new("/run/user/111/podman/podman.sock"),
"the DOCKER_HOST a rootless unit must carry"
);
assert_eq!(Account::from_passwd(ODEN_PASSWD, "nosuch"), None);
}
// ── the subid files ────────────────────────────────────────────────────
/// **A file that is ALREADY WRONG must survive being read.**
///
/// oden's `/etc/subuid` grants `github` and `alice` the identical range —
/// impossible from shadow-utils, present anyway, and complained about by
/// nothing. The parser reads it, [`SubIdFile::existing_overlaps`] reports it,
/// and no code path repairs it: rewriting somebody else's grant would break
/// whatever is running inside it right now.
///
/// RED direction: make the parser dedupe or renumber overlapping grants and the
/// round-trip assertion fails.
#[test]
fn an_already_overlapping_subuid_file_is_read_reported_and_left_alone() {
let odens = "# a comment\ngithub:100000:65536\nalice:100000:65536\n";
let f = SubIdFile::parse(odens);
assert_eq!(f.ranges().count(), 2);
assert_eq!(
f.existing_overlaps(),
vec![(
"github:100000:65536".to_string(),
"alice:100000:65536".to_string()
)],
"the overlap must be REPORTED — it is invisible otherwise"
);
assert_eq!(
f.render(),
odens,
"and the file must round-trip byte-for-byte, comment included"
);
}
/// Comments, blanks and malformed lines are carried verbatim. A rewriter that
/// normalises what it does not understand eventually deletes a real grant.
#[test]
fn unparseable_lines_round_trip_untouched() {
let messy = "# header\n\nalice:100000:65536\nbroken-line-no-colons\nx:notanumber:5\n";
let f = SubIdFile::parse(messy);
assert_eq!(f.ranges().count(), 1, "only one line is a real grant");
assert_eq!(f.render(), messy);
assert!(f.lines.iter().any(
|l| matches!(l, SubIdLine::Other(o) if o == "broken-line-no-colons")
));
}
/// `count` is a COUNT. Two ranges that touch but do not share an id are NOT an
/// overlap; one id of intersection is.
#[test]
fn range_overlap_is_computed_on_count_not_on_end() {
let a = SubIdRange {
owner: "a".into(),
start: 100_000,
count: 65_536,
};
assert_eq!(a.end(), 165_535);
let touching = SubIdRange {
owner: "b".into(),
start: 165_536,
count: 65_536,
};
assert!(!a.overlaps(&touching), "adjacent is not overlapping");
let by_one = SubIdRange {
owner: "c".into(),
start: 165_535,
count: 1,
};
assert!(a.overlaps(&by_one), "one shared id IS an overlap");
}
/// **The policy is READ, never invented** — `/etc/login.defs`, VERIFIED on t14s
/// as `100000 / 600100000 / 65536`. A silent file falls back to shadow's own
/// compiled defaults, not to a number this crate made up.
#[test]
fn the_allocation_policy_comes_from_login_defs() {
let real = "\
# comment
SUB_UID_MIN\t\t 100000
SUB_UID_MAX\t\t600100000
SUB_UID_COUNT\t\t 65536
SUB_GID_MIN\t\t 200000
SUB_GID_MAX\t\t600100000
SUB_GID_COUNT\t\t 65536
";
let u = SubIdPolicy::from_login_defs(real, IdKind::Uid);
assert_eq!(
u,
SubIdPolicy {
min: 100_000,
max: 600_100_000,
count: 65_536
}
);
let g = SubIdPolicy::from_login_defs(real, IdKind::Gid);
assert_eq!(g.min, 200_000, "the GID triple is read separately");
assert_eq!(
SubIdPolicy::from_login_defs("", IdKind::Uid),
SubIdPolicy::SHADOW_DEFAULT,
"an absent file falls back to shadow's compiled defaults"
);
}
/// **Allocation routes AROUND an overlapping file without repairing it**, and
/// lands on the same lattice the host's own tools use.
///
/// oden's hand-typed grant was `korp:296608:65536` = `100000 + 3*65536`. The
/// aligned-slot walk reproduces that arithmetic; given oden's two colliding
/// grants at slot 0 it takes the first free slot rather than the fourth,
/// because the file it is given has fewer occupants — the value is derived from
/// the file, which is the property that matters.
#[test]
fn allocation_skips_occupied_slots_and_never_invents_a_number() {
let subuid = SubIdFile::parse("github:100000:65536\nalice:100000:65536\n");
let subgid = SubIdFile::parse("github:100000:65536\nalice:100000:65536\n");
let r = allocate_pair("korp", &subuid, &subgid, SubIdPolicy::SHADOW_DEFAULT).unwrap();
assert_eq!(r.start, 165_536, "slot 0 is taken (twice) — take slot 1");
assert_eq!(r.count, 65_536);
assert!(
!subuid.collides(r.start, r.count),
"the whole point: no overlap with anything already granted"
);
// Slots 0..3 occupied ⇒ oden's own number falls out of the same walk.
let dense = SubIdFile::parse(
"a:100000:65536\nb:165536:65536\nc:231072:65536\n",
);
let r = allocate_pair("korp", &dense, &dense, SubIdPolicy::SHADOW_DEFAULT).unwrap();
assert_eq!(r.start, 296_608, "= 100000 + 3*65536, the value oden was given");
}
/// **Uid and gid are granted the SAME start**, so a slot free in one file and
/// taken in the other is skipped in both.
///
/// RED direction: allocate the two files independently and this returns 165536
/// for the uid — producing a service whose container files have an owner and a
/// group mapping to unrelated host ids.
#[test]
fn the_uid_and_gid_grants_are_allocated_as_one_pair() {
let subuid = SubIdFile::parse("alice:100000:65536\n");
let subgid = SubIdFile::parse("alice:100000:65536\nother:165536:65536\n");
let r = allocate_pair("korp", &subuid, &subgid, SubIdPolicy::SHADOW_DEFAULT).unwrap();
assert_eq!(
r.start, 231_072,
"slot 1 is free in subuid but taken in subgid, so neither file gets it"
);
}
/// An exhausted policy window is a REFUSAL, not a wrap-around into real uids.
#[test]
fn an_exhausted_policy_window_refuses_rather_than_wrapping() {
let full = SubIdFile::parse("a:1000:100\nb:1100:100\n");
let policy = SubIdPolicy {
min: 1000,
max: 1199,
count: 100,
};
let e = allocate_pair("korp", &full, &full, policy).unwrap_err();
let m = format!("{e:#}");
assert!(m.contains("no free subordinate-id range"), "{m}");
assert!(
m.contains("will not allocate outside the host's own policy window"),
"the refusal must say WHY it stopped: {m}"
);
}
// ── the assessment ─────────────────────────────────────────────────────
/// **The oden case end to end**, as a pure function: Ubuntu, systemd-logind,
/// helpers present, `korp` uid 111 with no grant and no linger. Three things
/// are needed, `/run/user/111` follows from the third, and the plan says
/// exactly that.
#[test]
fn the_oden_case_produces_a_four_change_plan_and_a_pending_runtime_dir() {
let r = assess(&inputs(Host::from_facts(ubuntu_facts())));
assert!(!r.is_ready(), "an unconfigured host is not ready");
assert!(r.blockers().is_empty(), "nothing blocks: {}", r.render());
assert!(
matches!(finding(&r, Step::IdMapTools).state, State::Satisfied(_)),
"newuidmap/newgidmap are present on this fixture"
);
let plan = r.plan();
assert_eq!(
plan.len(),
4,
"subuid + subgid + linger + the engine socket: {}",
r.render()
);
let text: String = plan.iter().map(|c| c.describe()).collect::<Vec<_>>().join("\n");
assert!(text.contains("/etc/subuid"), "{text}");
assert!(text.contains("/etc/subgid"), "{text}");
assert!(text.contains("SetUserLinger(111, true)"), "{text}");
assert!(text.contains("podman.socket"), "{text}");
// ORDER, not just membership: there is no user manager to enable a unit in
// until lingering has created one, so a plan that enabled the socket first
// would fail on precisely the hosts this crate exists for.
let linger_at = plan
.iter()
.position(|c| matches!(c, Change::EnableLinger { .. }))
.expect("the plan lingers");
let socket_at = plan
.iter()
.position(|c| matches!(c, Change::EnableUserSocket { .. }))
.expect("the plan enables the engine socket");
assert!(
linger_at < socket_at,
"lingering must be planned BEFORE the socket it makes reachable: {text}"
);
assert!(
text.contains("atomic temp-file + rename"),
"the dry run must say HOW it writes, because `echo >>` is what it replaces: {text}"
);
// The runtime dir is a CONSEQUENCE, never a mkdir.
let rd = &finding(&r, Step::RuntimeDir).state;
assert!(matches!(rd, State::Follows(_)), "got {rd:?}");
assert!(rd.detail().contains("/run/user/111"), "{}", rd.detail());
assert!(
rd.detail().contains("does not mkdir"),
"and must say why it will not create it: {}",
rd.detail()
);
}
/// **Already set up ⇒ nothing to do.** The inspection has to be able to say
/// "yes" or the apply can never be idempotent.
#[test]
fn a_host_that_is_already_set_up_reports_ready_with_an_empty_plan() {
let mut i = inputs(Host::from_facts(ubuntu_facts()));
i.subuid = Some("alice:100000:65536\nkorp:296608:65536\n".into());
i.subgid = Some("alice:100000:65536\nkorp:296608:65536\n".into());
i.linger_marker = true;
i.runtime_dir_owner = Some(111);
// The account has enabled the engine socket itself — the sixth step's
// satisfied shape. Without this the host is NOT set up, and that is the point
// of the step: `active but disabled` was what t14s looked like, and it read as
// finished right up until the reboot.
i.container_socket_enabled = true;
let r = assess(&i);
assert!(r.is_ready(), "{}", r.render());
assert!(r.plan().is_empty());
assert!(
apply(&r).unwrap().performed.is_empty(),
"a set-up host is a successful no-op, not an error"
);
assert!(
finding(&r, Step::SubUid).state.detail().contains("296608"),
"and it must name the grant it found"
);
}
/// **ALPINE — the case the family type exists for.**
///
/// Stock Alpine is OpenRC with no login manager. `loginctl enable-linger` is
/// not "unavailable" there, it is *meaningless*: no such binary, no such bus
/// name, no such concept, and nothing to create `/run/user/<uid>`. This must be
/// [`State::Unsupported`] with an explanation — a common interface that quietly
/// no-ops here would hand back a "configured" host and a service that cannot
/// start.
///
/// RED direction: make `Linger` fall through to `Satisfied` (or skip) on a
/// logind-less host and both the blocker and the apply-refusal assertions fail
/// while every systemd test stays green — exactly the silent half.
#[test]
fn stock_alpine_refuses_lingering_and_says_it_does_not_exist_there() {
let alpine = Host::from_facts(HostFacts {
os_release: "ID=alpine\nVERSION_ID=3.21.0\n".into(),
openrc_softlevel: true,
newuidmap: true,
newgidmap: true,
..Default::default()
});
let r = assess(&inputs(alpine));
let s = &finding(&r, Step::Linger).state;
assert!(matches!(s, State::Unsupported(_)), "got {s:?}");
assert!(
s.detail().contains("NO login manager") && s.detail().contains("openrc"),
"it must name the mechanism that is missing and the init that is running: {}",
s.detail()
);
assert!(
s.detail().contains("elogind"),
"…and the real answer on this family, so the refusal is actionable: {}",
s.detail()
);
// The subid grants on Alpine are perfectly ordinary — the file is the same
// file. Only the runtime-dir mechanism differs, and that is the finding.
assert!(
matches!(finding(&r, Step::SubUid).state, State::Needed { .. }),
"/etc/subuid is not a systemd concept and works here"
);
// …but the run as a whole refuses, because half of it would be pointless.
let e = apply(&r).unwrap_err();
let m = format!("{e:#}");
assert!(
m.contains("half-applied host setup is worse than none"),
"{m}"
);
assert!(m.contains("linger"), "and it must name the step: {m}");
}
/// **Alpine WITH elogind is a supported host**, by the same code path as
/// Ubuntu, because lingering is a logind capability and not a family one.
///
/// RED direction: key the refusal off `Family::Alpine` and this host — which can
/// genuinely do it — gets refused.
#[test]
fn alpine_with_elogind_can_linger_through_the_same_dbus_call() {
let alpine = Host::from_facts(HostFacts {
os_release: "ID=alpine\n".into(),
openrc_softlevel: true,
elogind_run: true,
newuidmap: true,
newgidmap: true,
..Default::default()
});
let r = assess(&inputs(alpine));
assert!(r.blockers().is_empty(), "{}", r.render());
match &finding(&r, Step::Linger).state {
State::Needed { change, .. } => assert_eq!(
change,
&Change::EnableLinger {
name: "korp".into(),
uid: 111,
logind: Logind::Elogind,
}
),
other => panic!("expected a linger change, got {other:?}"),
}
// subuid + subgid + linger + the engine socket. Alpine-with-elogind reaches the
// sixth step like any other host: the fixture ships podman's user unit, and
// elogind gives the account the manager that unit is enabled in.
assert_eq!(r.plan().len(), 4);
}
/// **A missing `newuidmap` stops the run and names the family's package.**
///
/// This is the Debian-specific trap: `uidmap` is a separate package a default
/// install does not pull in, so podman can be installed and rootless podman
/// still cannot map a second id. The error podman gives — "potentially
/// insufficient UIDs or GIDs available in user namespace" — reads like a subuid
/// problem, so the tool has to be explicit about which of the two it is.
#[test]
fn a_missing_newuidmap_blocks_and_names_the_package_for_this_family() {
let mut f = ubuntu_facts();
f.newuidmap = false;
let r = assess(&inputs(Host::from_facts(f)));
let s = &finding(&r, Step::IdMapTools).state;
assert!(matches!(s, State::Manual(_)), "got {s:?}");
assert!(s.detail().contains("apt-get install -y uidmap"), "{}", s.detail());
assert!(
s.detail().contains("will not do it"),
"installing a package is a subprocess and a host decision: {}",
s.detail()
);
assert!(apply(&r).is_err(), "and it must stop the run");
// The SAME missing tool on RHEL names a different package.
let rhel = Host::from_facts(HostFacts {
os_release: "ID=\"rocky\"\nID_LIKE=\"rhel centos fedora\"\n".into(),
systemd_run_system: true,
systemd_seats: true,
newuidmap: false,
newgidmap: false,
..Default::default()
});
let r = assess(&inputs(rhel));
assert!(
finding(&r, Step::IdMapTools)
.state
.detail()
.contains("dnf install -y shadow-utils"),
"{}",
finding(&r, Step::IdMapTools).state.detail()
);
}
/// **An unknown family is refused, not guessed at.** Nothing here can name the
/// package, so nothing here pretends to.
#[test]
fn an_unknown_family_with_missing_tools_is_refused_by_name() {
let host = Host::from_facts(HostFacts {
os_release: "ID=slackware\n".into(),
systemd_run_system: true,
systemd_seats: true,
..Default::default()
});
let r = assess(&inputs(host));
let s = &finding(&r, Step::IdMapTools).state;
assert!(matches!(s, State::Refused(_)), "got {s:?}");
assert!(s.detail().contains("slackware"), "{}", s.detail());
assert!(
s.detail().contains("will not guess a package name"),
"{}",
s.detail()
);
}
/// **NixOS is refused on the subid steps specifically**, because there the
/// append succeeds and then disappears at the next rebuild — the worst
/// possible outcome, a setup that works until it silently does not.
#[test]
fn nixos_refuses_to_append_to_a_generated_etc() {
let nixos = Host::from_facts(HostFacts {
os_release: "ID=nixos\n".into(),
systemd_run_system: true,
systemd_seats: true,
newuidmap: true,
newgidmap: true,
..Default::default()
});
let r = assess(&inputs(nixos));
for step in [Step::SubUid, Step::SubGid] {
let s = &finding(&r, step).state;
assert!(matches!(s, State::Refused(_)), "{step:?} → {s:?}");
assert!(s.detail().contains("nixos-rebuild"), "{}", s.detail());
assert!(
s.detail().contains("subUidRanges"),
"the refusal must name the declarative alternative: {}",
s.detail()
);
}
// Lingering is fine there — NixOS runs systemd-logind like anyone else.
assert!(matches!(
finding(&r, Step::Linger).state,
State::Needed { .. }
));
assert!(apply(&r).is_err());
}
/// **No passwd entry ⇒ refuse everything.** There is no uid to linger and no
/// name to grant, and creating the account is not this crate's decision.
#[test]
fn an_account_with_no_passwd_entry_is_refused_on_every_step() {
let mut i = inputs(Host::from_facts(ubuntu_facts()));
i.account_name = "ghost".into();
let r = assess(&i);
assert_eq!(r.account, None);
for step in [Step::SubUid, Step::SubGid, Step::Linger, Step::RuntimeDir] {
assert!(
matches!(finding(&r, step).state, State::Refused(_)),
"{step:?} must refuse: {:?}",
finding(&r, step).state
);
}
assert!(r.render().contains("ABSENT from /etc/passwd"));
assert!(apply(&r).is_err());
}
/// A `/run/user/<uid>` owned by somebody ELSE is refused rather than adopted —
/// the one case where the directory exists and is worse than absent.
#[test]
fn a_runtime_dir_owned_by_another_account_is_refused() {
let mut i = inputs(Host::from_facts(ubuntu_facts()));
i.runtime_dir_owner = Some(1000);
let r = assess(&i);
let s = &finding(&r, Step::RuntimeDir).state;
assert!(matches!(s, State::Refused(_)), "got {s:?}");
assert!(s.detail().contains("uid 1000"), "{}", s.detail());
}
/// **RED-when-broken: an engine socket that is merely RUNNING is not set up.**
///
/// The t14s case of 2026-08-15, as a fixture. Every other step was satisfied and
/// `podman.socket` was `active` — started on demand by a client that happened to ask
/// — while being `disabled`. That host works and stops working at the next reboot,
/// which is the worst shape a "configured" verdict can have.
///
/// So enablement is read from the SYMLINK, never from liveness: this crate has no
/// input for "is it running", deliberately, because the answer would have been yes.
///
/// RED direction: make the step consult a liveness fact, or default
/// `container_socket_enabled` to true, and `is_ready` goes green on a host whose
/// containers die at reboot.
#[test]
fn a_socket_that_is_running_but_not_enabled_is_still_needed() {
let mut i = inputs(Host::from_facts(ubuntu_facts()));
i.subuid = Some("alice:100000:65536\nkorp:296608:65536\n".into());
i.subgid = Some("alice:100000:65536\nkorp:296608:65536\n".into());
i.linger_marker = true;
i.runtime_dir_owner = Some(111);
i.container_socket_enabled = false;
let r = assess(&i);
assert!(
!r.is_ready(),
"a host whose engine socket is not ENABLED is not set up: {}",
r.render()
);
let s = &finding(&r, Step::ContainerSocket).state;
assert!(matches!(s, State::Needed { .. }), "got {s:?}");
assert!(
s.detail().contains("reboot"),
"the detail must say what actually breaks, not just that a flag is off: {}",
s.detail()
);
assert_eq!(
r.plan().len(),
1,
"everything else is already done — only the socket is left: {}",
r.render()
);
match &r.plan()[0] {
Change::EnableUserSocket {
name,
uid,
gid,
unit_path,
link,
} => {
assert_eq!((name.as_str(), *uid, *gid), ("korp", 111, 111));
assert_eq!(unit_path, "/usr/lib/systemd/user/podman.socket");
// The link must land in the ACCOUNT's home, under the target whose
// `.wants` systemd actually pulls at boot — not in root's home, and not
// in `/etc/systemd/user` (which would enable it for every account on the
// box, an install deciding something about people it was never asked
// about).
assert_eq!(
link,
Path::new("/home/korp/.config/systemd/user/sockets.target.wants/podman.socket")
);
}
other => panic!("expected an EnableUserSocket change, got {other:?}"),
}
}
/// **RED-when-broken: enabling writes the link, hands it to the account, and is
/// idempotent — and never re-owns the packaged unit it points at.**
///
/// The ownership half is the subtle one. `chown` on a symlink FOLLOWS it, so the
/// obvious spelling would have handed `/usr/lib/systemd/user/podman.socket` — a file
/// belonging to the podman package — to a service account. `lchown` is what makes the
/// link itself the thing that changes hands. This test pins the packaged file's owner
/// before and after to keep that honest.
///
/// The uid/gid assertions only run as root (nothing else may chown), so the ownership
/// half is skipped for an unprivileged test run rather than faked; the linking and
/// idempotency halves always run.
#[test]
fn enabling_writes_the_wants_link_and_is_idempotent() {
let dir = tempfile::tempdir().unwrap();
let unit_src = dir.path().join("podman.socket");
std::fs::write(&unit_src, "[Socket]\nListenStream=%t/podman/podman.sock\n").unwrap();
let packaged_before = std::fs::metadata(&unit_src).unwrap();
let home = dir.path().join("home/korp");
let link = user_wants_link(&home.to_string_lossy(), "podman.socket");
let unit_path = unit_src.to_string_lossy().into_owned();
// The ids this process already owns, read off a file it just created — so the
// chowns are no-ops and the test runs unprivileged. (`libc` is not a dependency of
// this crate, and adding one to learn our own uid would be a poor trade.) The
// path, link and idempotency behaviour is what this test is about.
let (uid, gid) = {
use std::os::unix::fs::MetadataExt;
let m = std::fs::metadata(&unit_src).unwrap();
(m.uid(), m.gid())
};
enable_user_unit(&link, &unit_path, uid, gid).expect("enable writes the link");
assert_eq!(
std::fs::read_link(&link).unwrap(),
unit_src,
"the link must point at the packaged unit"
);
assert!(
link.parent().unwrap().ends_with("sockets.target.wants"),
"…inside the target's own .wants dir, or nothing pulls it at boot"
);
// The packaged unit is untouched — the `lchown`-not-`chown` property.
let packaged_after = std::fs::metadata(&unit_src).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
assert_eq!(
(packaged_before.uid(), packaged_before.gid()),
(packaged_after.uid(), packaged_after.gid()),
"chowning the link must not follow it into the packaged unit file"
);
}
// Idempotent: a second run is a no-op success, not a failed `symlink` on an
// existing path (an install runs more than once by design).
enable_user_unit(&link, &unit_path, uid, gid).expect("a second enable is a no-op");
assert_eq!(std::fs::read_link(&link).unwrap(), unit_src);
// A link aiming somewhere else is CORRECTED — an install asserts a state.
std::fs::remove_file(&link).unwrap();
std::os::unix::fs::symlink("/nonexistent/other.socket", &link).unwrap();
enable_user_unit(&link, &unit_path, uid, gid).expect("a stale link is replaced");
assert_eq!(std::fs::read_link(&link).unwrap(), unit_src);
// And a unit file that does not exist is REFUSED rather than linked: a dangling
// .wants entry is a complaint at every boot.
let missing = dir.path().join("nope.socket");
let err = enable_user_unit(&link, &missing.to_string_lossy(), uid, gid)
.expect_err("a missing unit file must be refused");
assert!(format!("{err:#}").contains("does not exist"), "{err:#}");
}
/// **A host with no engine is a HOST fact, stated as one — and it stops the run.**
///
/// `Manual`, the same state [`Step::IdMapTools`] uses for a missing package, and it
/// blocks for the same reason the crate blocks everywhere else: half-applied is worse
/// than none. Setting up subids and lingering on a box with no container engine
/// produces a host that LOOKS configured for rootless containers and cannot run one —
/// which is exactly the "configured but broken" verdict every other step refuses to
/// hand back.
///
/// The consumer's side of that trade is deliberate too: `install_service` treats a
/// rootless refusal as non-fatal, so the service still installs and still runs — it
/// just does not pretend its container leg is ready.
#[test]
fn a_host_with_no_user_socket_unit_names_the_package_and_blocks_like_a_missing_tool() {
let mut i = inputs(Host::from_facts(ubuntu_facts()));
i.container_socket_unit = None;
let r = assess(&i);
let s = &finding(&r, Step::ContainerSocket).state;
assert!(matches!(s, State::Manual(_)), "got {s:?}");
assert!(s.detail().contains("podman"), "names the package: {}", s.detail());
assert!(
s.detail().contains("/usr/lib/systemd/user"),
"and says where it looked: {}",
s.detail()
);
assert!(
s.is_blocking(),
"a missing engine blocks, exactly as a missing newuidmap does — the two \
findings must read the same way in one report"
);
// And the refusal must NAME this step, not just count blockers: an operator
// reading it has to learn which package to install.
let m = format!("{:#}", apply(&r).unwrap_err());
assert!(m.contains("container-socket"), "{m}");
assert!(m.contains("podman"), "{m}");
}
/// The refusal message lists EVERY blocker, not the first — an operator who
/// fixes one and re-runs into the next has been made to do the work twice.
#[test]
fn the_apply_refusal_lists_every_blocker_at_once() {
let mut f = ubuntu_facts();
f.newuidmap = false;
f.systemd_seats = false; // …and no logind either
let r = assess(&inputs(Host::from_facts(f)));
assert!(r.blockers().len() >= 2, "{}", r.render());
let m = format!("{:#}", apply(&r).unwrap_err());
assert!(m.contains("idmap-tools"), "{m}");
assert!(m.contains("linger"), "{m}");
}
// ── writing ────────────────────────────────────────────────────────────
/// **The write is atomic and preserves the mode** — the property `echo >>`
/// cannot have. An interrupted append leaves a truncated grant line, and
/// shadow-utils ignores a subid file from the first bad line onward.
#[test]
fn granting_a_range_rewrites_the_file_atomically_and_keeps_its_mode() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("subuid");
std::fs::write(&file, "alice:100000:65536\n").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o600)).unwrap();
}
let range = SubIdRange {
owner: "korp".into(),
start: 165_536,
count: 65_536,
};
let report = Report {
host: Host::from_facts(ubuntu_facts()),
account_name: "korp".into(),
account: Account::from_passwd(ODEN_PASSWD, "korp"),
findings: vec![Finding {
step: Step::SubUid,
state: State::Needed {
detail: "no grant".into(),
change: Change::GrantSubId {
file: file.clone(),
range: range.clone(),
},
},
}],
existing_subuid_overlaps: vec![],
};
let done = apply(&report).unwrap();
assert_eq!(done.performed.len(), 1);
assert_eq!(
std::fs::read_to_string(&file).unwrap(),
"alice:100000:65536\nkorp:165536:65536\n",
"the existing grant is untouched and the new one is appended"
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_eq!(
std::fs::metadata(&file).unwrap().permissions().mode() & 0o777,
0o600,
"a rename must not hand /etc/subuid a fresh default mode"
);
}
// No staging file left behind.
let leftovers: Vec<_> = std::fs::read_dir(dir.path())
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.file_name().to_string_lossy().contains("tmp"))
.collect();
assert!(leftovers.is_empty(), "the temp file must be renamed, not left");
// Idempotent under a RACE: applying the same plan again finds the grant
// already there (as if another writer had won) and leaves it alone.
let again = apply(&report).unwrap();
assert!(
again.performed[0].contains("already granted by another writer"),
"{:?}",
again.performed
);
assert_eq!(
std::fs::read_to_string(&file).unwrap(),
"alice:100000:65536\nkorp:165536:65536\n",
"and it must not append a second time"
);
}
/// The rendered report is the crisp one-line-per-step form, and it names the
/// pre-existing overlap without offering to fix it.
#[test]
fn the_report_renders_one_line_per_step_and_notes_a_broken_file() {
let mut i = inputs(Host::from_facts(ubuntu_facts()));
i.subuid = Some("github:100000:65536\nalice:100000:65536\n".into());
let text = assess(&i).render();
for step in ["idmap-tools", "subuid", "subgid", "linger", "runtime-dir"] {
assert!(text.contains(step), "missing {step} in:\n{text}");
}
assert!(text.contains("overlapping grants"), "{text}");
assert!(text.contains("repairs nothing it did not write"), "{text}");
}
/// The read-only path runs on whatever box the tests run on, touches nothing,
/// and produces a report. `root` always has a passwd entry, so this exercises
/// the account branch for real.
#[test]
fn inspecting_the_running_host_is_read_only_and_always_answers() {
let r = inspect("root");
assert_eq!(r.account_name, "root");
assert_eq!(r.findings.len(), 6, "all six steps are always reported");
assert!(!r.render().is_empty());
// Whatever this host is, the report must not claim a mutation happened.
assert!(r.plan().iter().all(|c| matches!(
c,
Change::GrantSubId { .. }
| Change::EnableLinger { .. }
| Change::EnableUserSocket { .. }
)));
}
}