nordisk/Skidbladnir
crates/skidbladnir-systemd/src/systemd.rs
//! Reusable systemd-service installer — the extracted, parameterized form of
//! `nornir install systemd`.
//!
//! nornir's own installer (`src/bin/nornir.rs::run_install_systemd`) hardcodes
//! everything for the `nornir` service: the `nornir` system user, the unit text,
//! the `/etc/nornir/nornir.env` env file, the daemon-reload/enable/start dance.
//! This module lifts those pieces out into a generic, spec-driven library so
//! **holger** (and any other nordisk product) can install itself the same way —
//! without nornir or holger being swapped over yet (create-only).
//!
//! Two layers, sharply split so the generators are unit-testable with NO root:
//!
//! 1. **Pure generators** ([`systemd_unit`](crate::systemd::systemd_unit), [`env_file`](crate::systemd::env_file)) — deterministic
//! `String` output, no filesystem, no subprocess, no privilege. These are the
//! "generate" pieces the task calls for; they are what the tests assert on.
//! 2. **Privileged install** ([`install_systemd`](crate::systemd::install_systemd)) — `#[cfg(unix)]`, root-only:
//! create the system user if missing, write the unit + env file, daemon-reload,
//! enable, start. A clear-error stub on non-unix, exactly like nornir.
//!
//! Design + guide: `.nornir/airgap-installer.md`.
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
/// systemd `Restart=` policy for the rendered unit.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum RestartPolicy {
/// `Restart=no` — never auto-restart.
No,
/// `Restart=on-failure` — restart on non-clean exit (nornir's default).
#[default]
OnFailure,
/// `Restart=always` — restart whenever the process stops.
Always,
}
impl RestartPolicy {
/// The systemd `Restart=` keyword.
pub fn as_systemd(self) -> &'static str {
match self {
RestartPolicy::No => "no",
RestartPolicy::OnFailure => "on-failure",
RestartPolicy::Always => "always",
}
}
}
/// **Why an OOM-reaction spelling was REFUSED at the place it was written.**
///
/// Same posture as [`MemoryBoundRefused`]: `OOMPolicy=` takes exactly three words
/// and systemd's parser is CASE-SENSITIVE, so `Stop` or `continu` does not "mostly
/// work" — the unit fails to load in full, and the failure surfaces at
/// `systemctl start` on a box, hours from the human who typed it. This carries the
/// offending text so the refusal can quote it back.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OomPolicyRefused {
/// The text that is not one of the three legal spellings, verbatim.
pub text: String,
}
impl std::fmt::Display for OomPolicyRefused {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"REFUSING OOMPolicy={:?} — systemd takes exactly `continue`, `stop` or `kill`, \
lower-case, and rejects the whole unit for anything else. A reaction to being \
OOM-killed that cannot be parsed is refused where it is written, not discovered \
when the service will not start",
self.text
)
}
}
impl std::error::Error for OomPolicyRefused {}
/// **WHAT HAPPENS TO THE UNIT WHEN THE KERNEL REAPS ONE OF ITS PROCESSES — the
/// other half of [`MemoryBound`].**
///
/// A ceiling says WHEN. This says WHAT NEXT. They are two different facts and the
/// second one was, until this type existed, left to whatever the box's
/// `DefaultOOMPolicy=` happened to be — the exact "declare the build, don't
/// remember it" failure the memory axis was added to end one setting earlier.
///
/// systemd's own three words (`man systemd.service`, `OOMPolicy=`, added in
/// systemd 243; read on systemd 259, 2026-08-25):
///
/// * [`OomPolicy::Continue`] — a process of the unit is killed by the OOM killer,
/// this is LOGGED and **the unit keeps running**.
/// * [`OomPolicy::Stop`] — the event is logged and the service manager terminates
/// the unit's processes cleanly.
/// * [`OomPolicy::Kill`] — the kernel is told to reap every REMAINING process of the
/// unit too, by setting the cgroup-v2 `memory.oom.group` attribute to 1.
///
/// For both `stop` and `kill` the unit ends in the `oom-kill` failed state, "after
/// which `Restart=` may apply" — so the pair [`SystemdServiceSpec::oom_policy`] +
/// [`SystemdServiceSpec::restart`] is what turns an OOM kill into a restart rather
/// than a service that is simply gone.
///
/// This also governs **systemd-oomd**, not just the kernel killer: the man page
/// states the same setting decides the unit's state after `systemd-oomd` kills a
/// cgroup associated with it.
///
/// # The DEFAULT, measured rather than assumed
///
/// There is deliberately no `#[derive(Default)]` here and the spec field is an
/// `Option`: "undeclared" is a real, distinct state, and it is NOT a fixed value.
/// Per `man systemd.service`, an absent `OOMPolicy=` takes the manager-wide
/// `DefaultOOMPolicy=` from `systemd-system.conf(5)` — **except** for units with
/// `Delegate=` turned on, which default to `continue` instead.
///
/// MEASURED on t14s (systemd 259, 2026-08-25), which is why this is written down
/// and not guessed: `systemctl show -p DefaultOOMPolicy` ⇒ `stop`;
/// `/etc/systemd/system.conf:80` carries `#DefaultOOMPolicy=stop` (the shipped
/// default, commented); `systemctl show -p OOMPolicy -p Delegate ssh.service` ⇒
/// `stop` / `Delegate=no`, and the same for `systemd-journald.service`; while
/// `user@1000.service` ⇒ `OOMPolicy=continue` / `Delegate=yes`, confirming the
/// delegation exception on a live unit.
///
/// Skidbladnir renders no `Delegate=` line at all, so every unit it writes falls in
/// the first branch: **an undeclared reaction is the box's `DefaultOOMPolicy=`, and
/// on a stock systemd that is `stop`.** It is a per-box setting an operator can
/// change, which is precisely why a service with an opinion should DECLARE it rather
/// than inherit it.
///
/// # Why an enum and not a `String`
///
/// The parser is case-sensitive and takes three words; anything else makes systemd
/// reject the entire unit, not just the directive. A free string would carry a typo
/// from the caller's source into `/etc/systemd/system/<svc>.service` and surface it
/// as a service that will not start. [`OomPolicy::from_str`] is the ONE string-facing
/// seam (a CLI flag, a TOML field) and refuses by name, listing the legal spellings.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum OomPolicy {
/// `OOMPolicy=continue` — log the kill, **keep the unit running**. The policy for
/// a service whose reaped process was a CHILD it can afford to lose: a forge that
/// spawns `rustc` must not go down because one build blew up.
Continue,
/// `OOMPolicy=stop` — terminate the unit's processes cleanly, land in the
/// `oom-kill` failed state, let `Restart=` bring it back. The policy for a service
/// that IS its memory: half of it having been reaped is not a state worth limping
/// on in.
Stop,
/// `OOMPolicy=kill` — set `memory.oom.group=1` so the kernel reaps every remaining
/// process of the unit as well. The policy for a unit whose processes only make
/// sense together, where one survivor is a leak rather than a service.
Kill,
}
impl OomPolicy {
/// The systemd `OOMPolicy=` keyword. Lower-case, because systemd's parser is.
pub fn as_systemd(self) -> &'static str {
match self {
OomPolicy::Continue => "continue",
OomPolicy::Stop => "stop",
OomPolicy::Kill => "kill",
}
}
/// **The inverse — read a systemd `OOMPolicy=` value back into a policy.**
///
/// `None` for anything that is not one of the three legal spellings, in exactly
/// the sense [`MemoryBound::parse_bytes`] returns `None` for `infinity`: a value
/// this crate cannot interpret is reported as uninterpretable rather than guessed
/// at. Deliberately case-SENSITIVE, matching systemd — `Stop` is not a variant
/// spelling of `stop`, it is a unit that will not load.
///
/// It exists so a declared and a hand-written drop-in reaction can be compared by
/// MEANING rather than by text ([`crate::dropin::scan_dropins_in`]), the same
/// reason `parse_bytes` exists for sizes.
pub fn parse(text: &str) -> Option<OomPolicy> {
match text.trim() {
"continue" => Some(OomPolicy::Continue),
"stop" => Some(OomPolicy::Stop),
"kill" => Some(OomPolicy::Kill),
_ => None,
}
}
}
impl std::str::FromStr for OomPolicy {
type Err = OomPolicyRefused;
/// The string-facing seam — a CLI flag or a config field. Refuses BY NAME,
/// quoting the offending text and listing the three legal spellings, so an
/// operator typo dies where it was typed instead of in a unit systemd will not
/// load. Note this is stricter than a trim-and-shrug: `"kill "` is refused, because
/// a value that needs cleaning up before it is legal is a value a human got wrong.
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"continue" => Ok(OomPolicy::Continue),
"stop" => Ok(OomPolicy::Stop),
"kill" => Ok(OomPolicy::Kill),
other => Err(OomPolicyRefused {
text: other.to_string(),
}),
}
}
}
impl std::fmt::Display for OomPolicy {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_systemd())
}
}
/// **Why an OOM-score bias was REFUSED at the place it was written.**
///
/// One variant, because there is exactly one way to get it wrong: a number outside
/// the kernel's range. Same posture as its three siblings — judged where a human
/// typed it, carrying the value and the bounds.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OomScoreAdjustRefused {
/// The out-of-range value that was written.
pub value: i32,
}
impl std::fmt::Display for OomScoreAdjustRefused {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"REFUSING OOMScoreAdjust={} — the kernel takes an integer from -1000 \
(never pick this unit) to 1000 (pick it first), and systemd rejects the whole \
unit for anything outside that. MEASURED on t14s: DefaultOOMScoreAdjust=0, \
ssh.service=0, systemd-journald.service=-250",
self.value
)
}
}
impl std::error::Error for OomScoreAdjustRefused {}
/// **WHICH unit the kernel picks when the BOX is out of memory — `OOMScoreAdjust=`.**
///
/// The last axis of the OOM story and the only one that is about the neighbours. The
/// ceiling ([`MemoryBound`]) stops one service growing; the reaction ([`OomPolicy`])
/// decides what happens to it once something in it is reaped. Neither says anything
/// about WHO gets reaped when the machine as a whole runs out — and on 2026-08-20 the
/// answer was `gunnar-service`, taken alongside the forge that actually caused it,
/// because every unit on the box sat at the same score.
///
/// MEASURED on t14s (systemd 259, 2026-08-25): `DefaultOOMScoreAdjust=0`,
/// `ssh.service` = 0, `systemd-journald.service` = **-250** — the journal already
/// buys itself protection this way, so this is the mechanism the OS itself uses to
/// rank its own services, not a novelty.
///
/// ── THE PRIVILEGE RULE, measured rather than assumed ──────────────────────────
/// Written into `/proc/<pid>/oom_score_adj`. With `CapEff: 0000000000000000` on this
/// box: raising the value (200 -> 500) SUCCEEDED, lowering within the non-negative
/// range (500 -> 100) SUCCEEDED, and a NEGATIVE value (-500) **FAILED**. Protection
/// needs `CAP_SYS_RESOURCE`; making yourself a better target does not.
///
/// That asymmetry decides how the axis is used. A service can always volunteer to die
/// first. Only a privileged installer can promise a service it will be spared — which
/// is why a negative bias on a `Scope::User` unit is a promise the manager cannot
/// keep, and why the sacrificial direction is the one a forge should be declaring
/// about ITSELF rather than a protective one about everybody else.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct OomScoreAdjust(i32);
impl OomScoreAdjust {
/// The kernel's floor — "never pick processes of this unit". Needs
/// `CAP_SYS_RESOURCE`, per the measurement above.
pub const NEVER: i32 = -1000;
/// The kernel's ceiling — "pick processes of this unit first".
pub const FIRST: i32 = 1000;
/// Refuses anything outside `-1000..=1000`, by name, quoting the value and the
/// bounds — systemd rejects the entire unit for an out-of-range number, so a typo
/// here is a service that will not load.
pub fn new(value: i32) -> Result<Self, OomScoreAdjustRefused> {
if !(Self::NEVER..=Self::FIRST).contains(&value) {
return Err(OomScoreAdjustRefused { value });
}
Ok(OomScoreAdjust(value))
}
/// **Volunteer to be reaped BEFORE the rest of the box** — a positive bias.
///
/// The direction a service that legitimately grows should declare about itself: a
/// forge whose build children are the reason the machine is short should be the
/// thing the kernel reaches for, so that the services which merely happened to be
/// running are not. Unprivileged, per the measurement, so it works in `Scope::User`
/// too.
pub fn sacrifice_first() -> Self {
OomScoreAdjust(500)
}
/// The raw value, as systemd and the kernel spell it.
pub fn value(self) -> i32 {
self.0
}
/// Is this the protective direction — the half that needs `CAP_SYS_RESOURCE`?
pub fn needs_privilege(self) -> bool {
self.0 < 0
}
}
impl std::fmt::Display for OomScoreAdjust {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
/// **Why a start rate limit was REFUSED at the place it was written.**
///
/// Same posture as [`MemoryBoundRefused`] and [`OomPolicyRefused`]: the numbers are
/// judged where a human typed them, not after a unit has been written around them.
/// Both variants carry every number involved, because a refusal an operator cannot
/// act on is a refusal that gets worked around.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StartLimitRefused {
/// `StartLimitBurst=0` beside a live interval. systemd reads this as "disable the
/// limiter", but it READS like "permit zero starts" — two opposite meanings for
/// one spelling. Refused so the intent has to be said out loud with
/// [`StartLimit::never_give_up`].
BurstZero {
/// The interval it was paired with.
interval_sec: u32,
},
/// **The defect this whole axis exists for.** The restart cadence and the rate
/// limit meet within one restart period of each other, so whether the breaker
/// EVER fires is decided by process-start jitter rather than by the declaration.
JitterDecided {
/// `StartLimitIntervalSec=`.
interval_sec: u32,
/// `StartLimitBurst=`.
burst: u32,
/// `RestartSec=` — the other half of the pair.
restart_sec: u32,
/// `restart_sec * burst`: when the (burst+1)-th attempt is due.
span_sec: u64,
},
}
impl std::fmt::Display for StartLimitRefused {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
StartLimitRefused::BurstZero { interval_sec } => write!(
f,
"REFUSING StartLimitBurst=0 beside StartLimitIntervalSec={interval_sec} — \
systemd reads burst=0 as DISABLING the limiter, but it reads to a human as \
`permit zero starts`. Say which you meant: StartLimit::never_give_up() to \
disable it, or a burst >= 1 to keep it"
),
StartLimitRefused::JitterDecided {
interval_sec,
burst,
restart_sec,
span_sec,
} => write!(
f,
"REFUSING StartLimitIntervalSec={interval_sec}/StartLimitBurst={burst} beside \
RestartSec={restart_sec} — the (burst+1)-th attempt is due at \
{restart_sec}*{burst} = {span_sec}s, which is within one restart period of \
the {interval_sec}s window. systemd denies the start when {span_sec} <= \
{interval_sec} and RESETS the window when it does not, so these numbers do \
not decide between `park in failed` and `retry forever` — scheduling jitter \
does. MEASURED on t14s: real restart deltas ran 2.066-2.250s against a \
declared RestartSec=2, and dwarves-server.service thrashed 217830 times \
without the breaker ever firing. Separate them by at least one restart \
period: StartLimit::give_up_after(attempts, restart_sec) to make it fire, \
StartLimit::never_give_up() to turn it off"
),
}
}
}
impl std::error::Error for StartLimitRefused {}
/// **The start rate limit — `StartLimitIntervalSec=` + `StartLimitBurst=`, declared
/// as one pair because they only mean anything together.**
///
/// ── THE DEFECT, MEASURED 2026-08-25 on t14s (systemd 259) ────────────────────────
/// This crate ships `RestartSec=2` (a default it OWNS) and, until now, rendered no
/// rate limit at all — so every unit inherited `DefaultStartLimitBurst=5` and
/// `DefaultStartLimitIntervalSec=10s` from whatever box it landed on. Two halves of
/// ONE behaviour, set by two different layers, never reconciled.
///
/// systemd denies a start when `now - window_begin <= interval` and the burst is
/// spent, and RESETS the window otherwise. With attempts every `restart_sec`, the
/// (burst+1)-th is due at `restart_sec * burst` — so the ENTIRE behaviour turns on:
///
/// ```text
/// restart_sec * burst < interval ⇒ breaker FIRES, unit parks in `failed`
/// restart_sec * burst > interval ⇒ window resets, unit retries FOREVER
/// restart_sec * burst == interval ⇒ decided by process-start jitter
/// ```
///
/// `2 * 5 == 10`. The shipped default lands exactly on the third line.
///
/// What that produced, measured on the live `dwarves-server.service` (user scope,
/// `~/.config/systemd/user/dwarves-server.service`): `RestartUSec=2s`,
/// `StartLimitIntervalUSec=10s`, `StartLimitBurst=5`, and in `journalctl --user`
/// **217830** `Scheduled restart job` lines over 14 days, one run reaching restart
/// counter **77998**, with **ZERO** `start-limit-hit` events. The breaker was not
/// nearly firing — by arithmetic it could not fire, because real restart deltas
/// measured **2.066s–2.250s** (exec + teardown on top of the declared 2s), putting
/// the 6th attempt at 10.33–11.25s, just past the 10s window every time. A faster
/// box, a smaller binary or a warm cache pushes that under 10s and the same spec
/// parks permanently instead. **The sign of the comparison was decided by process
/// startup latency.**
///
/// So the constructors do not take raw numbers by preference. They take the INTENT —
/// [`give_up_after`](StartLimit::give_up_after) or
/// [`never_give_up`](StartLimit::never_give_up) — and compute a pair that is a full
/// restart period clear of the boundary. [`StartLimit::new`] is the raw door and
/// [`check_against`](StartLimit::check_against) refuses a jitter-decided pair by name.
///
/// **Undeclared renders nothing**, exactly like [`MemoryBound`] and [`OomPolicy`]: an
/// existing unit must not change by one byte. The cost of that choice is that an
/// undeclared spec still inherits the box's window — which is the residual risk, and
/// it is named here rather than papered over with a silent library-wide default.
///
/// Note the two halves live in DIFFERENT SECTIONS of the same file: `RestartSec=` is
/// a `[Service]` key and `StartLimitIntervalSec=`/`StartLimitBurst=` are `[Unit]`
/// keys (`man systemd.unit`). That split is a fair part of why they drifted.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct StartLimit {
/// `StartLimitIntervalSec=` in seconds; `0` disables rate limiting entirely.
/// Private so the invariant cannot be side-stepped by a struct literal.
interval_sec: u32,
/// `StartLimitBurst=` — starts permitted per interval.
burst: u32,
}
impl StartLimit {
/// **Make the breaker FIRE, deterministically, after `attempts` tries.**
///
/// Sets `burst = attempts` and `interval = restart_sec * (attempts + 1)`: the
/// (attempts+1)-th try is due at `restart_sec * attempts`, a full restart period
/// inside the window, so the denial happens even with the 12%-of-a-period jitter
/// measured on t14s. `give_up_after(5, 2)` ⇒ `StartLimitIntervalSec=12`,
/// `StartLimitBurst=5` — the 6th attempt at 10s (11.25s with real jitter) is
/// inside 12s either way.
///
/// This is the DEFENSIBLE default for a service, and it deliberately makes the
/// limit STRICTER than the inherited one rather than looser. A unit parked in
/// `failed` is discoverable — `systemctl --failed`, `is-failed`, and this crate's
/// own status verb all report it. 77998 silent restarts are not discoverable by
/// anything. Same ruling as the OpenRC partial: a NAMED refusal beats a silent
/// one.
pub fn give_up_after(attempts: u32, restart_sec: u32) -> Result<Self, StartLimitRefused> {
let interval_sec = restart_sec
.saturating_mul(attempts.saturating_add(1))
.max(1);
let l = StartLimit {
interval_sec,
burst: attempts,
};
l.check()?;
Ok(l)
}
/// **Turn the limiter OFF, explicitly — `StartLimitIntervalSec=0`.**
///
/// The documented disable (`man systemd.unit`: interval "may be set to 0 to
/// disable any kind of rate limiting"). Exact, with no margin to argue about,
/// which is why "retry forever" is spelled this way and never by tuning the
/// interval under the span.
///
/// Choose it for a service where a permanent park is worse than a thrash — but
/// choose it OUT LOUD. Inheriting a window that never fires, which is what
/// dwarves-server was doing 217830 times, is this behaviour arrived at by
/// accident.
pub fn never_give_up() -> Self {
StartLimit {
interval_sec: 0,
burst: 0,
}
}
/// The raw door — both numbers, as systemd spells them. Refuses `burst = 0`
/// beside a live interval, whose systemd meaning ("disable") is the opposite of
/// its plain reading ("permit zero starts").
///
/// It canNOT check the pair against the restart cadence, because the cadence is
/// not here; that is [`check_against`](StartLimit::check_against), which
/// [`SystemdServiceSpec::validate`] applies before anything is written.
pub fn new(interval_sec: u32, burst: u32) -> Result<Self, StartLimitRefused> {
let l = StartLimit {
interval_sec,
burst,
};
l.check()?;
Ok(l)
}
fn check(&self) -> Result<(), StartLimitRefused> {
if self.burst == 0 && self.interval_sec != 0 {
return Err(StartLimitRefused::BurstZero {
interval_sec: self.interval_sec,
});
}
Ok(())
}
/// **THE CROSS-FIELD GUARD: is this pair actually decided, given the cadence?**
///
/// Refuses when `restart_sec * burst` lands within one restart period of the
/// interval — the zone where, as measured, the outcome is chosen by exec latency
/// instead of by the spec. Outside that zone the declaration decides:
/// `span < interval` parks the unit, `span > interval` retries forever, and both
/// are legitimate things to mean.
///
/// A disabled limiter (`interval_sec == 0`) is always fine — there is no boundary
/// to sit on.
pub fn check_against(&self, restart_sec: u32) -> Result<(), StartLimitRefused> {
self.check()?;
if self.interval_sec == 0 {
return Ok(());
}
let span_sec = u64::from(restart_sec).saturating_mul(u64::from(self.burst));
if span_sec.abs_diff(u64::from(self.interval_sec)) < u64::from(restart_sec) {
return Err(StartLimitRefused::JitterDecided {
interval_sec: self.interval_sec,
burst: self.burst,
restart_sec,
span_sec,
});
}
Ok(())
}
/// `StartLimitIntervalSec=` in seconds. `0` means rate limiting is off.
pub fn interval_sec(self) -> u32 {
self.interval_sec
}
/// `StartLimitBurst=` — starts permitted per interval.
pub fn burst(self) -> u32 {
self.burst
}
/// Does this pair make the breaker fire at all, at the given cadence? The
/// question `check_against` refuses to let jitter answer.
pub fn fires_at(self, restart_sec: u32) -> bool {
self.interval_sec != 0
&& u64::from(restart_sec).saturating_mul(u64::from(self.burst))
<= u64::from(self.interval_sec)
}
}
/// Install scope — the generic form of nornir's `--scope`. Picks WHERE the unit
/// lives and WHO runs it:
///
/// - [`Scope::System`] — a root-owned unit in `/etc/systemd/system` running as a
/// dedicated system user ([`SystemdServiceSpec::user`]); the [`install_systemd`]
/// default. State under that user's home.
/// - [`Scope::User`] — a `systemctl --user` unit in `~/.config/systemd/user`
/// running as the invoking user. No root, no system user, no `/etc`; state in
/// that user's own home. The `User=`/`Group=` lines are OMITTED (systemd rejects
/// them in `--user` units).
///
/// [`Scope::resolve`] picks by privilege when the caller doesn't force one — root
/// → `System`, non-root → `User` — mirroring nornir's `resolve_scope`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum Scope {
/// Root unit in `/etc/systemd/system`, runs as the spec's system user.
#[default]
System,
/// `systemctl --user` unit in `~/.config/systemd/user`, runs as the caller.
User,
}
impl Scope {
/// Pick the scope: an explicit choice wins; otherwise `System` when running as
/// root (euid 0), `User` otherwise. Mirrors nornir's `resolve_scope`.
#[cfg(unix)]
pub fn resolve(explicit: Option<Scope>) -> Scope {
explicit.unwrap_or(if unsafe { geteuid() } == 0 {
Scope::System
} else {
Scope::User
})
}
/// Non-unix: the installers are linux-only, so only `System` is meaningful —
/// but keep the signature so callers compile everywhere.
#[cfg(not(unix))]
pub fn resolve(explicit: Option<Scope>) -> Scope {
explicit.unwrap_or(Scope::System)
}
fn is_user(self) -> bool {
matches!(self, Scope::User)
}
}
/// **Why a [`MemoryBound`] was REFUSED at the place it was written.**
///
/// Same posture as [`crate::uninstall::DataRootRefused`]: a declaration that can
/// never behave as written fails where a human typed it, not deep inside an
/// install where the only symptom is a unit systemd loads and a bound nobody has.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MemoryBoundRefused {
/// Neither ceiling was given. A `Some(MemoryBound)` carrying no numbers *reads*
/// like a bound and behaves like `None` — the worst of both. An undeclared axis
/// is spelled `None`.
NeitherCeiling,
/// A ceiling of **zero bytes**. `which` names the axis (`"soft (MemoryHigh=)"`
/// / `"hard (MemoryMax=)"`).
///
/// systemd reads `MemoryMax=0` literally — "this cgroup may hold no pages" —
/// so the service is killed the instant it faults in its first page. Zero is
/// never what a human means; it is *delete spelled wrong*, exactly like the
/// `keep_last=0` that would have swept every kernel in edda's retention sweep.
/// The way to have no bound is to declare no bound.
Zero {
/// Which ceiling was zero, as it reads in a message.
which: &'static str,
},
/// The HARD ceiling sits BELOW the soft one, so the throttle point can never be
/// reached: the cgroup is OOM-killed before it is ever reclaimed. Both numbers
/// are carried so the refusal can name them.
HardBelowSoft {
/// The soft ceiling, in bytes (`MemoryHigh=`).
soft_bytes: u64,
/// The hard ceiling, in bytes (`MemoryMax=`).
hard_bytes: u64,
},
}
impl std::fmt::Display for MemoryBoundRefused {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
MemoryBoundRefused::NeitherCeiling => write!(
f,
"REFUSING a memory bound that declares NEITHER ceiling — a bound with no \
numbers in it is not a bound. An undeclared resource axis is spelled by \
declaring nothing, not by declaring an empty something"
),
MemoryBoundRefused::Zero { which } => write!(
f,
"REFUSING a {which} memory ceiling of 0 bytes — zero is not \"no limit\", it \
is \"this service may hold no memory\", and systemd honours it literally: \
the cgroup is killed the instant it faults in a page. Zero is delete spelled \
wrong (the `keep_last=0` reading); the way to have no ceiling is to declare none"
),
MemoryBoundRefused::HardBelowSoft {
soft_bytes,
hard_bytes,
} => write!(
f,
"REFUSING MemoryHigh={} ({soft_bytes} bytes) with MemoryMax={} ({hard_bytes} \
bytes) — the HARD ceiling is BELOW the soft one, so the throttle-and-reclaim \
point can never be reached: the cgroup is OOM-killed at {hard_bytes} before it \
is ever slowed down at {soft_bytes}. A declaration that cannot behave as \
written is refused where it is written",
MemoryBound::systemd_spelling(*soft_bytes),
MemoryBound::systemd_spelling(*hard_bytes),
),
}
}
}
impl std::error::Error for MemoryBoundRefused {}
/// **A RESOURCE CEILING on the service's cgroup — the axis whose absence took the
/// whole box down.**
///
/// Two DIFFERENT facts, deliberately not collapsed into one number:
///
/// * a **soft** ceiling (systemd `MemoryHigh=`) — past it the cgroup is throttled
/// and aggressively reclaimed. The service gets SLOW. It keeps running.
/// * a **hard** ceiling (systemd `MemoryMax=`) — past it **this** cgroup is
/// OOM-killed, and `Restart=` brings it back. The kernel does not go roaming the
/// box for something else to kill.
///
/// The value is stored in **BYTES**, as an integer, never as a string like `"16G"`:
/// a stringly-typed size cannot be compared (is `"16G"` above `"20000M"`?),
/// cannot be validated, and cannot be re-spelled per backend — systemd wants `16G`,
/// Kubernetes wants `16Gi`, podman wants `16g`, and all three mean the same
/// 17179869184. The spelling is derived from the number at render time
/// ([`MemoryBound::systemd_spelling`] and friends), so there is ONE authority for
/// the size and N renderings of it.
///
/// # Why this exists — dwarf, 2026-08-20
///
/// `dwarves-server` reached **33 GB RSS**. Its unit declared no memory bound of any
/// kind, so nothing stood between the process and all 62 GB of the box: the kernel
/// OOM killer picked it *and* `gunnar-service`, and the machine went down. The
/// failure mode was never "the service dies" — with `Restart=on-failure` that is a
/// two-second blip — it was **"the box dies"**, because an unbounded cgroup makes
/// every other service on the host a candidate.
///
/// The only route available at the time was a hand-written
/// `/etc/systemd/system/dwarves-server.service.d/10-bounded.conf`, which is precisely
/// the artefact dwarves' own law forbids: *"NEVER hand-write a
/// `<unit>.service.d/*.conf`: a drop-in is owned by nobody, survives uninstall and
/// re-applies to the next install."* The law was violated because the declaration
/// had nowhere to live. This is where it lives.
///
/// # Refusals
///
/// Built through checked constructors ([`MemoryBound::soft_and_hard`],
/// [`MemoryBound::soft_only`], [`MemoryBound::hard_only`], [`MemoryBound::new`]),
/// which refuse — BY NAME, naming the numbers — a hard ceiling below the soft one,
/// a zero ceiling, and a bound that declares neither. [`MemoryBound::check`]
/// re-asserts the same invariant on a value that arrived by `serde` (a deserialiser
/// can reach the private fields; a validated constructor alone would be a guard with
/// a door beside it), and [`SystemdServiceSpec::validate`] calls it.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct MemoryBound {
/// Soft ceiling in BYTES — systemd `MemoryHigh=`. Private so the invariant
/// cannot be side-stepped by a struct literal; read it with
/// [`MemoryBound::soft_bytes`].
#[serde(default, skip_serializing_if = "Option::is_none")]
soft_bytes: Option<u64>,
/// Hard ceiling in BYTES — systemd `MemoryMax=`. Read it with
/// [`MemoryBound::hard_bytes`].
#[serde(default, skip_serializing_if = "Option::is_none")]
hard_bytes: Option<u64>,
}
impl MemoryBound {
/// 1 KiB in bytes — the unit systemd, Kubernetes and podman all count in.
pub const KIB: u64 = 1024;
/// 1 MiB in bytes.
pub const MIB: u64 = 1024 * 1024;
/// 1 GiB in bytes.
pub const GIB: u64 = 1024 * 1024 * 1024;
/// The general constructor: either ceiling, or both. Refuses zero, refuses a
/// bound with neither ceiling, and refuses a hard ceiling below the soft one.
pub fn new(
soft_bytes: Option<u64>,
hard_bytes: Option<u64>,
) -> Result<Self, MemoryBoundRefused> {
let b = MemoryBound {
soft_bytes,
hard_bytes,
};
b.check()?;
Ok(b)
}
/// A SOFT ceiling only — the cgroup is throttled and reclaimed past `bytes`, and
/// never killed for it. The gentle half of the axis on its own.
pub fn soft_only(bytes: u64) -> Result<Self, MemoryBoundRefused> {
Self::new(Some(bytes), None)
}
/// A HARD ceiling only — past `bytes` THIS cgroup is OOM-killed and `Restart=`
/// brings it back, instead of the kernel choosing a victim from the whole box.
pub fn hard_only(bytes: u64) -> Result<Self, MemoryBoundRefused> {
Self::new(None, Some(bytes))
}
/// Both ceilings — the shape the 2026-08-20 incident calls for: get slow at
/// `soft_bytes`, get restarted at `hard_bytes`, never take the box with you.
pub fn soft_and_hard(soft_bytes: u64, hard_bytes: u64) -> Result<Self, MemoryBoundRefused> {
Self::new(Some(soft_bytes), Some(hard_bytes))
}
/// Ergonomic [`MemoryBound::soft_and_hard`] in **GiB** — `gib(16, 24)` is the
/// bound the hand-written `10-bounded.conf` was carrying. Still stored in bytes.
pub fn gib(soft: u64, hard: u64) -> Result<Self, MemoryBoundRefused> {
Self::soft_and_hard(soft * Self::GIB, hard * Self::GIB)
}
/// The soft ceiling in bytes, if declared.
pub fn soft_bytes(&self) -> Option<u64> {
self.soft_bytes
}
/// The hard ceiling in bytes, if declared.
pub fn hard_bytes(&self) -> Option<u64> {
self.hard_bytes
}
/// Re-assert the invariant on a bound that did NOT come from a constructor —
/// i.e. one deserialised out of a product's airgap bundle, where `serde` reaches
/// the private fields directly. [`SystemdServiceSpec::validate`] calls this, so a
/// bundle carrying an impossible bound is refused before anything is written.
pub fn check(&self) -> Result<(), MemoryBoundRefused> {
if self.soft_bytes.is_none() && self.hard_bytes.is_none() {
return Err(MemoryBoundRefused::NeitherCeiling);
}
if self.soft_bytes == Some(0) {
return Err(MemoryBoundRefused::Zero {
which: "soft (MemoryHigh=)",
});
}
if self.hard_bytes == Some(0) {
return Err(MemoryBoundRefused::Zero {
which: "hard (MemoryMax=)",
});
}
if let (Some(soft), Some(hard)) = (self.soft_bytes, self.hard_bytes) {
// `hard == soft` is ALLOWED and deliberately not refused: it says "start
// reclaiming at exactly the point you would be killed", which is a
// coherent (if narrow) choice — reclaim runs first and may still save the
// process. `hard < soft` is not coherent: the kill happens strictly before
// the throttle can ever engage, so the soft ceiling is dead text.
if hard < soft {
return Err(MemoryBoundRefused::HardBelowSoft {
soft_bytes: soft,
hard_bytes: hard,
});
}
}
Ok(())
}
/// `MemoryHigh=` value for the soft ceiling, in systemd's own spelling.
pub fn systemd_high(&self) -> Option<String> {
self.soft_bytes.map(Self::systemd_spelling)
}
/// `MemoryMax=` value for the hard ceiling, in systemd's own spelling.
pub fn systemd_max(&self) -> Option<String> {
self.hard_bytes.map(Self::systemd_spelling)
}
/// `resources.requests.memory` for the soft ceiling, in Kubernetes' spelling.
pub fn k8s_request(&self) -> Option<String> {
self.soft_bytes.map(Self::k8s_spelling)
}
/// `resources.limits.memory` for the hard ceiling, in Kubernetes' spelling.
pub fn k8s_limit(&self) -> Option<String> {
self.hard_bytes.map(Self::k8s_spelling)
}
/// `podman run --memory=` value for the HARD ceiling (podman's `--memory` is
/// `memory.max` — the same cgroup file `MemoryMax=` writes).
pub fn podman_memory(&self) -> Option<String> {
self.hard_bytes.map(Self::podman_spelling)
}
/// `podman run --memory-reservation=` value for the SOFT ceiling. The nearest
/// thing podman exposes — see [`crate::service::ServiceSpec::memory`] for why it
/// is *near* and not *equal*.
pub fn podman_reservation(&self) -> Option<String> {
self.soft_bytes.map(Self::podman_spelling)
}
/// **The one-line human form used in a `NOT HONOURED` note**, naming BOTH
/// numbers in both spellings, so an operator reading a rendered init script knows
/// exactly what was asked for and can grant it by hand.
pub fn describe(&self) -> String {
let mut parts: Vec<String> = Vec::new();
if let Some(b) = self.soft_bytes {
parts.push(format!(
"soft/MemoryHigh={} ({b} bytes)",
Self::systemd_spelling(b)
));
}
if let Some(b) = self.hard_bytes {
parts.push(format!(
"hard/MemoryMax={} ({b} bytes)",
Self::systemd_spelling(b)
));
}
parts.join(", ")
}
/// Bytes → systemd's size spelling (`K`/`M`/`G` are **1024**-based in systemd,
/// unlike SI). An exact multiple gets the largest exact suffix; anything else
/// stays a plain byte count, which systemd also accepts — so the rendered value
/// is never a rounded lie.
pub fn systemd_spelling(bytes: u64) -> String {
binary_spelling(bytes, ["", "K", "M", "G", "T"])
}
/// Bytes → Kubernetes' `Ki`/`Mi`/`Gi` quantity spelling (also 1024-based).
pub fn k8s_spelling(bytes: u64) -> String {
binary_spelling(bytes, ["", "Ki", "Mi", "Gi", "Ti"])
}
/// Bytes → podman/docker's `k`/`m`/`g` spelling (1024-based on both).
pub fn podman_spelling(bytes: u64) -> String {
binary_spelling(bytes, ["", "k", "m", "g"])
}
/// **The inverse of the spellings — read a systemd size back into BYTES.**
///
/// `"16G"` → `17179869184`, `"16384M"` → the same number, `"17179869184"` → itself.
/// systemd's `K`/`M`/`G`/`T`/`P` are 1024-based (not SI) and an optional trailing
/// `B` is allowed, so `16G`, `16g` and `16GB` all mean the same thing here, exactly
/// as they do to systemd.
///
/// `None` for anything that is not a plain size — notably `infinity`, systemd's
/// spelling of "no ceiling", which is a real value and deliberately NOT mapped to
/// a number.
///
/// It exists so two spellings of one size can be COMPARED instead of
/// string-matched: `crate::dropin` has to decide whether a hand-written
/// `MemoryMax=24G` contradicts a rendered `MemoryMax=25769803776`, and by bytes it
/// plainly does not.
pub fn parse_bytes(text: &str) -> Option<u64> {
let t = text.trim();
if t.is_empty() {
return None;
}
// Drop one optional trailing `B` (`16GB` == `16G`), but never turn a bare `B`
// or a bare number's last digit into a suffix.
let t = match t.strip_suffix(['B', 'b']) {
Some(rest) if !rest.is_empty() && !rest.chars().all(|c| c.is_ascii_digit()) => rest,
_ => t,
};
let (digits, exp) = match t.chars().last()? {
'K' | 'k' => (&t[..t.len() - 1], 1u32),
'M' | 'm' => (&t[..t.len() - 1], 2),
'G' | 'g' => (&t[..t.len() - 1], 3),
'T' | 't' => (&t[..t.len() - 1], 4),
'P' | 'p' => (&t[..t.len() - 1], 5),
c if c.is_ascii_digit() => (t, 0),
// `infinity` and anything else is not a size; say so rather than guess.
_ => return None,
};
let n: u64 = digits.trim().parse().ok()?;
n.checked_mul(1u64.checked_shl(10 * exp)?)
}
}
/// Largest EXACT binary unit for `bytes` out of `units` (`units[0]` is the
/// no-suffix, plain-bytes form; `units[i]` is 1024^i). Exact only: 1.5 GiB renders
/// as `1536M`, and a prime number of bytes renders as itself — a size that would
/// have to be rounded is never rounded, because the whole point of storing bytes is
/// that the number survives the round trip.
fn binary_spelling<const N: usize>(bytes: u64, units: [&str; N]) -> String {
for i in (1..N).rev() {
let Some(div) = 1u64.checked_shl(10 * i as u32) else {
continue;
};
if bytes % div == 0 {
return format!("{}{}", bytes / div, units[i]);
}
}
format!("{bytes}{}", units[0])
}
/// A single `KEY=VALUE` pair rendered as a unit `Environment=` line (and written
/// to the env file when [`SystemdServiceSpec::env_file`] is set).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EnvVar {
pub key: String,
pub value: String,
}
impl EnvVar {
pub fn new(key: impl Into<String>, value: impl Into<String>) -> Self {
EnvVar {
key: key.into(),
value: value.into(),
}
}
}
/// Everything `run_install_systemd` currently hardcodes for nornir, but generic.
///
/// Build it with [`SystemdServiceSpec::new`] + the `with_*` chainers, or fill the
/// public fields directly. The two pure generators ([`systemd_unit`] /
/// [`env_file`]) and the privileged [`install_systemd`] all read from this one spec.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SystemdServiceSpec {
/// The unit name WITHOUT the `.service` suffix (e.g. `"nornir"`, `"holger"`).
/// `unit_file_name()` adds the suffix; the installer writes
/// `/etc/systemd/system/<service_name>.service`.
pub service_name: String,
/// `[Unit] Description=` text.
pub description: String,
/// The binary `ExecStart=` runs. Should be an installed, on-system path
/// (nornir copies to `/usr/local/bin` to avoid 203/EXEC off a removable drive).
pub exec_path: String,
/// Args appended to `exec_path` on the `ExecStart=` line (space-joined).
pub exec_args: Vec<String>,
/// System user to create + run as (`User=` / `Group=`). The installer creates
/// it if missing.
pub user: String,
/// `Group=`. Defaults to `user` when `None`.
pub group: Option<String>,
/// `SupplementaryGroups=` — ADDITIONAL groups the process joins, beyond the
/// single primary [`group`](SystemdServiceSpec::group). Rendered as one
/// space-separated directive; empty ⇒ no line at all.
///
/// This is what lets a `User=`-confined service open a resource owned by
/// another account's group without becoming root. See
/// [`crate::service::ServiceSpec::supplementary_group`] for the oden podman-socket
/// measurement that forced it into the declaration.
#[serde(default)]
pub supplementary_groups: Vec<String>,
/// `WorkingDirectory=`. RENDER-ONLY — this is NOT a chown target. It has a
/// non-optional default (`/` for a spec that declares none), which is exactly
/// why it must never be fed to `ensure_system_user`: see [`service_home`].
///
/// [`service_home`]: SystemdServiceSpec::service_home
pub working_dir: String,
/// The service's OWN home/state tree — the ONE directory the privileged install
/// may create and re-own to the service user.
///
/// `None` means "this spec declares no home", and the installer then creates and
/// chowns NOTHING. This is deliberately an `Option` while [`working_dir`] is not:
/// INCIDENT 2026-07-22 — a spec that set no working dir fell back to the rendered
/// default `/`, which `install_systemd_scoped` then passed to `ensure_system_user`
/// as the service home, recursively re-owning the filesystem root. A missing home
/// must be ABSENT, never a default that happens to be a real directory.
///
/// [`working_dir`]: SystemdServiceSpec::working_dir
#[serde(default)]
pub service_home: Option<String>,
/// **THE data root** — the ONE absolute directory this product keeps all of its
/// state in, identical whoever owns the process. Not derived from `$HOME`, not
/// from the CWD, not branched on [`Scope`]. It is what
/// [`crate::service::Lifecycle::uninstall`] erases, and it is validated by
/// [`crate::uninstall::DataRoot`] (a root that is or contains a real user's home
/// is refused). `None` ⇒ this spec declares no data root and its uninstall erases
/// nothing — and says so.
#[serde(default)]
pub data_root: Option<String>,
/// Pre-fixed-root data roots (e.g. a per-user `~/.dwarves` from before the data
/// root was pinned) that an uninstall also sweeps, so a machine upgraded from the
/// old layout ends up genuinely clean instead of leaving orphans no future
/// uninstall can find. Each is reported by name.
#[serde(default)]
pub legacy_data_roots: Vec<String>,
/// **THE DECLARED PROGRAM PATH** — the directory (or directories) this unit's
/// `ExecStart=` binary is allowed to live in.
///
/// The project's own rule is that every system declares an installation path for the
/// PROGRAM and one for the DATA. The data half has been declared here since
/// [`data_root`](SystemdServiceSpec::data_root) landed; the program half never was,
/// and nothing checked where the binary a unit points at actually lives.
///
/// MEASURED on oden 2026-08-27: `dwarves-server --install` writes the unit around
/// [`std::env::current_exe`], so an install run from a build directory produced
/// `ExecStart=~/git/dwarves/target/release/dwarves-server` — a
/// machine-wide service executing out of a cargo target tree that the next
/// `cargo clean` deletes from under it. Every existing gate passed: the file was
/// there, it was a file, it was executable. **Existence is not durability.**
///
/// Empty ⇒ undeclared, and then only the build-tree refusal in
/// [`validate_exec_is_durable`](SystemdServiceSpec::validate_exec_is_durable)
/// applies. Non-empty ⇒ an `ExecStart` outside every declared directory is REFUSED,
/// not warned about: a warning on an install is a line in a log nobody reads until
/// the service is already gone.
#[serde(default)]
pub program_dirs: Vec<String>,
/// Sub-directories of [`data_root`](SystemdServiceSpec::data_root) this service
/// owns, for the rarer case where the root is shared. Empty ⇒ sweep every child
/// of the root.
#[serde(default)]
pub state_dirs: Vec<String>,
/// Inline `Environment=KEY=VALUE` lines AND the body of the env file.
/// These are rendered verbatim into the `.service` unit, which lives at a
/// **world-readable** path (`/etc/systemd/system/<svc>.service`, mode 0644) —
/// so NEVER put a secret here. Secrets go in [`secret_environment`].
///
/// [`secret_environment`]: SystemdServiceSpec::secret_environment
pub environment: Vec<EnvVar>,
/// Sensitive `KEY=VALUE` entries (tokens, passwords, join keys). Unlike
/// [`environment`], these are written ONLY into the mode-0600
/// [`env_file`](SystemdServiceSpec::env_file) — they are DELIBERATELY never
/// emitted as inline `Environment=` lines in the world-readable unit. Setting
/// any secret therefore REQUIRES an `env_file` (a private place for them to
/// live); [`validate`](SystemdServiceSpec::validate) enforces this and
/// [`install_systemd`] refuses to install a spec that violates it.
///
/// [`environment`]: SystemdServiceSpec::environment
#[serde(default)]
pub secret_environment: Vec<EnvVar>,
/// **OPERATOR environment — the deployment's own decisions, not the product's
/// defaults.** Rendered inline AFTER [`environment`], so for a key both declare
/// the operator's value is the one that takes effect (systemd takes the last
/// assignment of a variable within a unit).
///
/// It is a separate axis from [`environment`] for exactly one reason: an install
/// must be able to tell "the product's default" from "what this box was told", and
/// therefore to CARRY THE SECOND ONE FORWARD when the next install does not repeat
/// it. See [`operator_env_from_unit_text`] — that carry-forward is what closes the
/// one genuine advantage a hand-written `<unit>.service.d/*.conf` had over an
/// install, and it is why oden's `korp-server` addr, account and data paths lived in
/// drop-ins that no installer wrote and no uninstall removed.
///
/// World-readable, exactly like [`environment`] — NEVER a secret.
///
/// [`environment`]: SystemdServiceSpec::environment
#[serde(default)]
pub operator_environment: Vec<EnvVar>,
/// When set, the unit gets `EnvironmentFile=<path>` and [`install_systemd`]
/// writes [`env_file`]'s output there (mode 0600, created privately). nornir
/// uses `/etc/nornir/nornir.env`.
pub env_file: Option<String>,
/// Configuration files the install writes (atomically, at their own mode) and
/// the uninstall removes. See [`crate::service::ServiceSpec::config_files`].
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub config_files: Vec<crate::service::ConfigFile>,
/// `Restart=` policy.
pub restart: RestartPolicy,
/// `RestartSec=` (seconds). nornir uses `2`.
pub restart_sec: u32,
/// `Type=` (e.g. `simple`, `notify`).
pub service_type: String,
/// `RemainAfterExit=` — keep the unit ACTIVE after its process has exited.
///
/// Only meaningful with `Type=oneshot`, and there it is not a convenience:
/// it is what lets the caller READ HOW THE JOB ENDED. systemd garbage-
/// collects an inactive, unreferenced unit out of the manager's memory, and
/// with it `Result=` and `ExecMainStatus=`. `GetUnit` then answers
/// `NoSuchUnit` and every property reads as "unknown" — indistinguishable
/// from a job that never ran.
///
/// MEASURED on the njord appliance 2026-09-19: a migration job that took
/// 20 ms exited, was collected before the 200 ms poll, and its deploy was
/// reported FAILED although the schema had migrated cleanly. The cost of
/// the line is that the unit must be STOPPED afterwards, which is the
/// caller's job.
///
/// `false` ⇒ no line at all, so every existing unit renders byte-for-byte
/// as before.
#[serde(default)]
pub remain_after_exit: bool,
/// Units this one must start `After=` (network-online.target is always added).
pub after: Vec<String>,
/// Units this one `Wants=`.
pub wants: Vec<String>,
/// `WantedBy=` target in `[Install]`.
pub wanted_by: String,
/// Emit the always-on hardening line `NoNewPrivileges=true` (nornir does).
pub no_new_privileges: bool,
/// **A file the service's stdout AND stderr are appended to** —
/// `StandardOutput=append:<path>` + `StandardError=append:<path>`. `None` (the
/// default) emits neither line, so every unit rendered before this field existed
/// renders byte-for-byte the same.
///
/// # Why a file and not the journal
///
/// A `.service`'s output goes to the journal, and Skidbladnir has **no
/// journal-reading API** — no `sd-journal`, no `libsystemd`, and reading
/// `/var/log/journal/*.journal` means implementing the binary journal format.
/// So a service's own words — the READINESS MARKER it prints when it is actually
/// serving — were unreachable from this crate, and the only question a caller
/// could ask was `ActiveState == "active"`. **Active is not answering**: systemd
/// reports a `Type=simple` unit active the instant it *forks*, before the process
/// has parsed a flag, let alone bound a port.
///
/// `append:` (systemd ≥ 240; MEASURED here on systemd 259, 2026-08-15) hands the
/// process an fd on a plain file this crate can then read with `std::fs`. That is
/// the same stream [`crate::service_drive::ServiceDrive::await_marker`] scans, and
/// it makes a `service`-kind row's readiness observation the SAME shape as an
/// `exe`-kind row's (`draupnir::exe::ExeBoot::await_marker` over a captured pipe).
///
/// # What it costs
///
/// The output goes to the file *instead of* the journal, so `journalctl -u <unit>`
/// shows nothing for this service. Declare it for a driven/test service; a
/// long-lived production unit normally wants the journal and should leave it
/// `None`. The file is never rotated by systemd — the driver truncates it at
/// install (a stale marker from the previous run is the classic false green).
///
/// A path containing whitespace is REFUSED by
/// [`validate`](SystemdServiceSpec::validate): `StandardOutput=` is not
/// word-split the way `ExecStart=`/`Environment=` are, so [`systemd_word`]'s
/// quoting does not apply and a spacey path would silently produce a unit whose
/// output lands somewhere else (or nowhere).
#[serde(default)]
pub stdout_file: Option<String>,
/// **THE RESOURCE CEILING — `MemoryHigh=` (soft) and/or `MemoryMax=` (hard) on
/// this unit's cgroup.** `None` (the default) emits NEITHER directive, so every
/// unit rendered before this field existed renders byte-for-byte the same.
///
/// Rendered in **both scopes**. Unlike `User=`/`Group=`/`SupplementaryGroups=`,
/// a memory ceiling is perfectly valid in a `systemctl --user` unit: a user unit
/// gets its own cgroup under `user@<uid>.service`, and systemd delegates the
/// memory controller into that subtree. MEASURED on dwarf, systemd 259,
/// 2026-08-24 — `systemctl show user@1000.service` reports
/// `Delegate=yes` / `DelegateControllers=cpu memory pids`, and a transient
/// `systemd-run --user -p MemoryMax=64M -p MemoryHigh=32M` unit came back with
/// `memory.max` = 67108864 and `memory.high` = 33554432 in
/// `/sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service/app.slice/…`.
/// So this is deliberately NOT inside the `if !scope.is_user()` block: scoping it
/// there by reflex would silently drop a bound a user service can really have.
///
/// See [`crate::service::ServiceSpec::memory`] for the 2026-08-20 incident and
/// the per-backend map.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub memory: Option<MemoryBound>,
/// **THE REACTION — `OOMPolicy=`, what systemd does to this UNIT when the kernel
/// OOM killer (or systemd-oomd) reaps one of its processes.** `None` (the
/// default) emits NO directive, so every unit rendered before this field existed
/// renders byte-for-byte the same — and the unit then inherits the manager's
/// `DefaultOOMPolicy=` (MEASURED `stop` on systemd 259; see [`OomPolicy`] for the
/// full measurement and the `Delegate=yes` exception).
///
/// Rendered in **both scopes**, for the same reason [`memory`] is: it is a
/// property of the unit's own cgroup and its own manager, not a privilege
/// directive. A `systemctl --user` unit has an `OOMPolicy=` — `user@1000.service`
/// itself reports one.
///
/// [`memory`]: SystemdServiceSpec::memory
#[serde(default, skip_serializing_if = "Option::is_none")]
pub oom_policy: Option<OomPolicy>,
/// **THE RATE LIMIT the restart cadence runs into — `StartLimitIntervalSec=` +
/// `StartLimitBurst=`.** `None` (the default) emits NO directives, so every unit
/// rendered before this field existed renders byte-for-byte the same — and then
/// inherits the manager's `DefaultStartLimitIntervalSec=`/`DefaultStartLimitBurst=`
/// (MEASURED `10s`/`5` on systemd 259), which paired with this crate's own
/// `RestartSec=2` puts `restart_sec * burst` EXACTLY on the interval.
///
/// See [`StartLimit`] for what that produced on the live `dwarves-server.service`:
/// 217830 restarts, zero breaker hits, the outcome decided by 66-250ms of exec
/// latency. Validated against [`restart_sec`] by [`SystemdServiceSpec::validate`],
/// because neither number means anything without the other.
///
/// [`restart_sec`]: SystemdServiceSpec::restart_sec
#[serde(default, skip_serializing_if = "Option::is_none")]
pub start_limit: Option<StartLimit>,
/// **WHICH unit the kernel picks when the BOX runs out — `OOMScoreAdjust=`.**
/// `None` emits no directive, so the unit inherits the manager's level (MEASURED
/// `DefaultOOMScoreAdjust=0`). See [`OomScoreAdjust`] for the measured privilege
/// asymmetry between the protective and sacrificial directions.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub oom_score_adjust: Option<OomScoreAdjust>,
}
impl SystemdServiceSpec {
/// A spec with nornir-matching defaults filled in: `Type=simple`,
/// `Restart=on-failure` / `RestartSec=2`, `After=`/`Wants=network-online.target`,
/// `WantedBy=multi-user.target`, `NoNewPrivileges=true`. Set the rest with the
/// `with_*` chainers.
pub fn new(
service_name: impl Into<String>,
description: impl Into<String>,
exec_path: impl Into<String>,
user: impl Into<String>,
working_dir: impl Into<String>,
) -> Self {
// An EXPLICIT working dir doubles as the declared service home (the shape
// nornir/holger have always relied on); a spec built by projection from a
// `ServiceSpec` that declared none gets `service_home: None` instead.
let working_dir = working_dir.into();
SystemdServiceSpec {
service_name: service_name.into(),
description: description.into(),
exec_path: exec_path.into(),
exec_args: Vec::new(),
user: user.into(),
group: None,
supplementary_groups: Vec::new(),
service_home: Some(working_dir.clone()),
data_root: None,
legacy_data_roots: Vec::new(),
program_dirs: Vec::new(),
state_dirs: Vec::new(),
working_dir,
environment: Vec::new(),
secret_environment: Vec::new(),
operator_environment: Vec::new(),
env_file: None,
config_files: Vec::new(),
restart: RestartPolicy::default(),
restart_sec: 2,
service_type: "simple".into(),
remain_after_exit: false,
after: Vec::new(),
wants: Vec::new(),
wanted_by: "multi-user.target".into(),
no_new_privileges: true,
stdout_file: None,
memory: None,
oom_policy: None,
start_limit: None,
oom_score_adjust: None,
}
}
/// Declare **THE resource ceiling** on this unit's cgroup — `MemoryHigh=` and/or
/// `MemoryMax=`. See [`memory`](SystemdServiceSpec::memory).
pub fn with_memory(mut self, bound: MemoryBound) -> Self {
self.memory = Some(bound);
self
}
/// Declare **THE RATE LIMIT** the restart cadence runs into —
/// `StartLimitIntervalSec=`/`StartLimitBurst=`. See
/// [`start_limit`](SystemdServiceSpec::start_limit) and [`StartLimit`]. Validated
/// against `restart_sec` by [`validate`](SystemdServiceSpec::validate) rather than
/// here, because the two halves can be set in either order.
pub fn with_start_limit(mut self, limit: StartLimit) -> Self {
self.start_limit = Some(limit);
self
}
/// Declare **WHICH unit the kernel picks first** — `OOMScoreAdjust=`. See
/// [`OomScoreAdjust`].
pub fn with_oom_score_adjust(mut self, adj: OomScoreAdjust) -> Self {
self.oom_score_adjust = Some(adj);
self
}
/// Declare **THE REACTION** to one of this unit's processes being OOM-killed —
/// `OOMPolicy=`. See [`oom_policy`](SystemdServiceSpec::oom_policy) and
/// [`OomPolicy`] for what each of the three does and what an undeclared reaction
/// falls back to.
pub fn with_oom_policy(mut self, policy: OomPolicy) -> Self {
self.oom_policy = Some(policy);
self
}
/// Redirect the service's stdout AND stderr to `path`
/// (`StandardOutput=append:` + `StandardError=append:`) — see
/// [`stdout_file`](SystemdServiceSpec::stdout_file) for why this exists and what
/// it costs (the journal stops carrying this unit's output).
pub fn with_stdout_file(mut self, path: impl Into<String>) -> Self {
self.stdout_file = Some(path.into());
self
}
/// Append an `ExecStart` argument.
pub fn with_arg(mut self, arg: impl Into<String>) -> Self {
self.exec_args.push(arg.into());
self
}
/// Append an `Environment=`/env-file entry. **Not for secrets** — this value
/// is rendered inline into the world-readable unit; use [`with_secret_env`]
/// for tokens/passwords.
///
/// [`with_secret_env`]: SystemdServiceSpec::with_secret_env
pub fn with_env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.environment.push(EnvVar::new(key, value));
self
}
/// Append an OPERATOR env entry — a value THIS deployment chose, as opposed to a
/// product default set with [`with_env`]. It renders after the defaults (so it wins
/// for a shared key) and it is the set an install CARRIES FORWARD when the next
/// install does not repeat it. Never a secret: the unit is 0644.
///
/// [`with_env`]: SystemdServiceSpec::with_env
pub fn with_operator_env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.operator_environment.push(EnvVar::new(key, value));
self
}
/// Append a SECRET env entry (token/password/join key). It is written ONLY to
/// the mode-0600 env file — never inline in the world-readable unit. Adding a
/// secret makes an [`env_file`](SystemdServiceSpec::env_file) mandatory
/// (enforced by [`validate`](SystemdServiceSpec::validate) / [`install_systemd`]);
/// call [`with_env_file`](SystemdServiceSpec::with_env_file) too.
pub fn with_secret_env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.secret_environment.push(EnvVar::new(key, value));
self
}
/// Whether this spec carries any [`secret_environment`] entry.
///
/// [`secret_environment`]: SystemdServiceSpec::secret_environment
pub fn has_secrets(&self) -> bool {
!self.secret_environment.is_empty()
}
/// Pure invariant check (no IO, no root — unit-testable). A spec that carries
/// secret env entries MUST also declare an `env_file`: secrets are written
/// there (mode 0600) and NEVER inline in the world-readable unit, so without a
/// file they would have no private home. Returns `Err(reason)` when violated.
pub fn validate(&self) -> Result<(), String> {
let ctx = |e: String| format!("service `{}`: {e}", self.service_name.escape_debug());
// Stinky #17: the name becomes `<unit_dir>/<name>.service` and lands raw in the
// unit, so it must be a systemd unit name — no `/`, no `..`, no whitespace.
check_service_name(&self.service_name).map_err(ctx)?;
crate::service::ConfigFile::check_all(&self.config_files).map_err(ctx)?;
// Stinky #7: these directives take the rest of the line RAW (they are not
// word-split and nothing quotes them), so a newline would start a new directive
// — `ExecStartPre=` as root, from spec data serde read out of a bundle.
for (what, v) in [
("description", Some(&self.description)),
("exec_path", Some(&self.exec_path)),
("working_dir", Some(&self.working_dir)),
("env_file", self.env_file.as_ref()),
("stdout_file", self.stdout_file.as_ref()),
("service_home", self.service_home.as_ref()),
("data_root", self.data_root.as_ref()),
] {
if let Some(v) = v {
check_no_control(what, v).map_err(ctx)?;
}
}
for (what, list) in [
("legacy_data_roots", &self.legacy_data_roots),
("program_dirs", &self.program_dirs),
("state_dirs", &self.state_dirs),
] {
for v in list {
check_no_control(what, v).map_err(ctx)?;
}
}
if !self.user.is_empty() {
check_account_name("user", &self.user).map_err(ctx)?;
}
if let Some(g) = &self.group {
check_account_name("group", g).map_err(ctx)?;
}
for g in &self.supplementary_groups {
check_account_name("supplementary group", g).map_err(ctx)?;
}
for u in self.after.iter().chain(&self.wants) {
check_unit_ref("After=/Wants= unit", u).map_err(ctx)?;
}
if !self.wanted_by.is_empty() {
check_unit_ref("WantedBy= target", &self.wanted_by).map_err(ctx)?;
}
if !SERVICE_TYPES.contains(&self.service_type.as_str()) {
return Err(ctx(format!(
"Type=`{}` is not one of {}",
self.service_type.escape_debug(),
SERVICE_TYPES.join("|")
)));
}
for e in self
.environment
.iter()
.chain(&self.operator_environment)
.chain(&self.secret_environment)
{
check_env_key(&e.key).map_err(ctx)?;
}
if self.has_secrets() && self.env_file.is_none() {
return Err(format!(
"service `{}` sets {} secret env var(s) but no env_file — secrets are \
never rendered inline in the world-readable unit, so an EnvironmentFile= \
(mode 0600) is required; call with_env_file(..)",
self.service_name,
self.secret_environment.len()
));
}
// `StandardOutput=`/`StandardError=` take the REST OF THE LINE — they are not
// word-split the way `ExecStart=`/`Environment=` are, so `systemd_word`'s
// quoting has nothing to do here and a path with whitespace would install a
// unit whose output lands somewhere nobody looks. Refuse by name instead.
if let Some(f) = &self.stdout_file {
if f.trim().is_empty() || f.chars().any(char::is_whitespace) {
return Err(format!(
"service `{}`: stdout_file `{f}` is empty or contains whitespace — \
`StandardOutput=append:` takes the rest of the line and cannot be \
quoted, so this path would not be the file the driver reads",
self.service_name
));
}
if !std::path::Path::new(f).is_absolute() {
return Err(format!(
"service `{}`: stdout_file `{f}` is not an ABSOLUTE path — systemd \
resolves nothing relative and the marker file would not be found",
self.service_name
));
}
}
// The RESOURCE CEILING, re-checked here rather than trusted. Every
// constructor on `MemoryBound` validates, but `serde` reaches the private
// fields directly, so a spec deserialised out of a product's airgap bundle can
// still carry `MemoryMax=` below `MemoryHigh=` (a bound that is OOM-killed
// before it can ever be throttled) or a zero ceiling (delete spelled wrong).
// A guard with a door beside it is not a guard: refuse it here too, by name,
// naming both numbers.
if let Some(m) = &self.memory {
m.check()
.map_err(|e| format!("service `{}`: {e}", self.service_name))?;
}
// THE CROSS-FIELD ONE. Unlike the ceiling's, this invariant spans TWO fields —
// the rate limit and the restart cadence — so no constructor can hold it: a
// caller sets them in either order, and `StartLimit` never sees `restart_sec`.
// This is the first place both numbers exist at once, and it is still before
// anything is written, which is the whole requirement.
if let Some(l) = &self.start_limit {
l.check_against(self.restart_sec)
.map_err(|e| format!("service `{}`: {e}", self.service_name))?;
}
Ok(())
}
/// **Does the `ExecStart=` binary actually exist and is it executable?**
///
/// Deliberately NOT part of [`validate`](SystemdServiceSpec::validate), which is
/// pure and is called on specs describing hosts other than this one (container
/// and machine mappings, renderer tests). This one touches the filesystem, so it
/// belongs to the install path and is called there.
///
/// It exists because nothing anywhere stat'd `ExecStart` before writing a unit
/// around it. A wrong `--exec` therefore produced a unit systemd accepted, an
/// install that printed success, and exit 0 — with the previous build still
/// serving, because the new unit's start failed while the old process was never
/// asked to stop. Refusing the spec BEFORE any privileged step turns that into
/// one clear sentence at the only moment it can still be cheap to fix.
#[cfg(unix)]
pub fn validate_exec_on_disk(&self) -> Result<(), String> {
use std::os::unix::fs::PermissionsExt;
let path = std::path::Path::new(&self.exec_path);
if !path.is_absolute() {
return Err(format!(
"service `{}`: ExecStart= is `{}`, which is not an ABSOLUTE path — systemd \
resolves nothing relative and the unit would fail at start",
self.service_name, self.exec_path
));
}
let meta = std::fs::metadata(path).map_err(|e| {
format!(
"service `{}`: ExecStart= points at `{}`, which cannot be stat'd ({e}) — \
refusing to install a unit around a binary that is not there. This is the \
wrong-`--exec` case that installs cleanly, exits 0, and leaves the previous \
build serving.",
self.service_name, self.exec_path
)
})?;
if meta.is_dir() {
return Err(format!(
"service `{}`: ExecStart= points at `{}`, which is a DIRECTORY",
self.service_name, self.exec_path
));
}
if meta.permissions().mode() & 0o111 == 0 {
return Err(format!(
"service `{}`: ExecStart= points at `{}`, which is not EXECUTABLE (mode {:o})",
self.service_name,
self.exec_path,
meta.permissions().mode() & 0o7777
));
}
Ok(())
}
/// Declare **THE PROGRAM PATH(S)** — see
/// [`program_dirs`](SystemdServiceSpec::program_dirs). Replaces any previous
/// declaration.
pub fn with_program_dirs<I, S>(mut self, dirs: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.program_dirs = dirs.into_iter().map(Into::into).collect();
self
}
/// **Will the `ExecStart=` binary still be there tomorrow?**
///
/// [`validate_exec_on_disk`](SystemdServiceSpec::validate_exec_on_disk) asks whether
/// the binary EXISTS. This asks whether it will KEEP existing, and they are different
/// questions with the same green.
///
/// # The measurement
///
/// oden, 2026-08-27. `dwarves-server --install` builds its unit around
/// [`std::env::current_exe`], so running it from a build tree wrote
/// `ExecStart=~/git/dwarves/target/release/dwarves-server`. Existence,
/// file-ness and the exec bit all passed — and a `cargo clean` on that target dir
/// (routine here; a lane reclaimed 139 GB that way the same day) silently removes the
/// binary of a machine-wide service. The unit stays, the manager still reports it
/// enabled, and the next boot fails 203/EXEC.
///
/// # The two refusals
///
/// 1. **A CARGO TARGET TREE.** Detected by the artefact cargo itself writes, not by a
/// path name: a directory holding BOTH a `CACHEDIR.TAG` whose first line is the
/// cache-directory signature AND a `.rustc_info.json`. That pair is exactly what
/// `cargo clean --target-dir <dir>` deletes, so the detector and the danger are
/// the same fact. A name-based check (`/target/`, `/debug/`) would both miss
/// `CARGO_TARGET_DIR=~/scratch/cargo/z-lane` and libel an innocent
/// `/opt/target/bin`.
/// 2. **OUTSIDE THE DECLARED PROGRAM PATH**, when one is declared. Compared on
/// canonicalised paths so a symlinked or `..`-laden spelling of the same directory
/// is accepted and a different directory is not.
///
/// Refusals, never warnings, and BEFORE any privileged step — which is the only
/// moment the mistake is still cheap.
#[cfg(unix)]
pub fn validate_exec_is_durable(&self) -> Result<(), String> {
let path = std::path::Path::new(&self.exec_path);
// (1) a cargo target tree. Walk every ancestor: the markers sit at the target
// dir's own root, which is one or two levels above the binary.
if let Some(target) = enclosing_cargo_target_dir(path) {
return Err(format!(
"service `{}`: ExecStart= is `{}`, which lives INSIDE the cargo target \
directory `{}` (it holds CACHEDIR.TAG + .rustc_info.json). A service must \
not execute out of a build tree: `cargo clean --target-dir {}` deletes \
that binary and the unit then fails 203/EXEC at the next start, with \
nothing having changed in the unit. Install the FILE into a program path \
first and point ExecStart at the installed copy.",
self.service_name,
self.exec_path,
target.display(),
target.display(),
));
}
// (2) outside the declared program path, when one is declared.
if !self.program_dirs.is_empty() {
let real = |p: &std::path::Path| -> std::path::PathBuf {
p.canonicalize().unwrap_or_else(|_| p.to_path_buf())
};
let dir = real(path.parent().unwrap_or(path));
let declared: Vec<std::path::PathBuf> = self
.program_dirs
.iter()
.map(|d| real(std::path::Path::new(d)))
.collect();
if !declared.contains(&dir) {
return Err(format!(
"service `{}`: ExecStart= is `{}`, whose directory `{}` is not one of \
the DECLARED program paths [{}]. The unit and the file are placed by \
two different steps, and this is the assertion that they agree — \
install the binary into a declared path and point ExecStart there.",
self.service_name,
self.exec_path,
dir.display(),
declared
.iter()
.map(|d| d.display().to_string())
.collect::<Vec<_>>()
.join(", "),
));
}
}
Ok(())
}
/// Declare the service's own home/state tree explicitly — the ONE directory the
/// privileged install may create and re-own. Pass `None` to declare none (then
/// the installer creates and chowns nothing).
pub fn with_service_home(mut self, home: Option<impl Into<String>>) -> Self {
self.service_home = home.map(Into::into);
self
}
/// Declare **THE data root** — see [`data_root`](SystemdServiceSpec::data_root).
/// Takes an already-validated [`crate::uninstall::DataRoot`], so a spec cannot
/// carry a root that is (or contains) a real user's home.
pub fn with_data_root(mut self, root: &crate::uninstall::DataRoot) -> Self {
self.data_root = Some(root.path().display().to_string());
self
}
/// Declare a pre-fixed-root data root an uninstall should also sweep — see
/// [`legacy_data_roots`](SystemdServiceSpec::legacy_data_roots).
pub fn with_legacy_data_root(mut self, root: impl Into<String>) -> Self {
self.legacy_data_roots.push(root.into());
self
}
/// Declare a state sub-directory of the data root this service owns. See
/// [`state_dirs`](SystemdServiceSpec::state_dirs).
pub fn with_state_dir(mut self, dir: impl Into<String>) -> Self {
self.state_dirs.push(dir.into());
self
}
/// The neutral slice [`crate::uninstall::plan_state_erase`] consumes — so the
/// systemd spec and the backend-neutral [`crate::service::ServiceSpec`] drive the
/// SAME resolver rather than each having its own idea of where state lives. That
/// is the whole fix: two ladders is how dwarves erased a path its own resolver
/// never used.
///
/// A spec that declares no [`data_root`](SystemdServiceSpec::data_root) falls back
/// to its `service_home` — the pre-0.1.13 implicit declaration — so an existing
/// consumer keeps working while it migrates. The fallback happens HERE, once, and
/// the planner still sees exactly one value.
pub fn state_spec(&self) -> crate::uninstall::StateSpec<'_> {
crate::uninstall::StateSpec {
service: &self.service_name,
user: Some(&self.user),
data_root: self.data_root.as_deref().or(self.service_home.as_deref()),
legacy_roots: &self.legacy_data_roots,
state_dirs: &self.state_dirs,
}
}
/// Route environment to an `EnvironmentFile=` (mode 0600) instead of (well, in
/// addition to) inline lines.
pub fn with_env_file(mut self, path: impl Into<String>) -> Self {
self.env_file = Some(path.into());
self
}
/// **This unit owns no state tree** — `WorkingDirectory=` stays, the chown
/// target goes.
///
/// [`SystemdServiceSpec::new`] treats an explicit working directory as the
/// declared service home, which is right for a service that OWNS its tree
/// (nornir, holger) and wrong for one that merely runs inside a tree owned
/// by somebody else. MEASURED on the njord machine 2026-09-19: four
/// environments were given an account each, and installing them refused with
/// *"service home for `yvra-prod` is not a safe chown target"* — correctly,
/// because it was about to re-own `/var/lib/yvra` away from the account that
/// owns it, one environment at a time.
///
/// The uninstall consequence is the point, not a side effect: a unit that
/// declares no home erases nothing when it is removed.
pub fn without_service_home(mut self) -> Self {
self.service_home = None;
self
}
/// `SupplementaryGroups=` — ADDITIONAL groups beyond the primary one.
///
/// The one non-root way for a `User=`-confined service to open a resource
/// owned by another account's group: a shared socket, or a file that belongs
/// to a peer it must read across. Empty leaves the directive out entirely,
/// so a spec that never calls this renders byte-for-byte as before.
pub fn with_supplementary_groups(
mut self,
groups: impl IntoIterator<Item = impl Into<String>>,
) -> Self {
self.supplementary_groups = groups.into_iter().map(Into::into).collect();
self
}
/// Set the `Restart=` policy.
pub fn with_restart(mut self, policy: RestartPolicy) -> Self {
self.restart = policy;
self
}
/// Add an `After=` unit edge.
pub fn after(mut self, unit: impl Into<String>) -> Self {
self.after.push(unit.into());
self
}
/// Add a `Wants=` unit edge.
pub fn wants(mut self, unit: impl Into<String>) -> Self {
self.wants.push(unit.into());
self
}
/// The unit file name (`<service_name>.service`).
pub fn unit_file_name(&self) -> String {
format!("{}.service", self.service_name)
}
/// The `Group=` value (`group` override, else `user`).
pub fn group_name(&self) -> &str {
self.group.as_deref().unwrap_or(&self.user)
}
/// What this spec CLAIMS the running service will be — the user, the group and
/// the inline `Environment=` lines it renders — in the primitive form
/// [`crate::dropin::scan_dropins_in`] checks a `<unit>.service.d/` against.
///
/// Secrets are deliberately excluded: [`secret_environment`] never reaches the
/// world-readable unit, so a drop-in cannot "override" it, and a conflict report
/// is printed to a terminal.
///
/// `scope` matters because a [`Scope::User`] unit renders NO `User=`/`Group=` at
/// all (it runs as the caller), and the declaration must match what was written
/// or every user-scope install would report a phantom conflict.
///
/// [`secret_environment`]: SystemdServiceSpec::secret_environment
pub fn declared_unit(&self, scope: Scope) -> crate::dropin::Declared {
let (user, group) = if scope.is_user() {
(String::new(), String::new())
} else {
(self.user.clone(), self.group_name().to_string())
};
crate::dropin::Declared {
user,
group,
// THE RESOURCE CEILING as this spec renders it, in the same spelling the
// fragment carries, so `scan_dropins_in` can tell a drop-in that AGREES
// with the declaration from one that OVERRIDES it. Empty ⇒ this spec
// declares none — and then ANY drop-in ceiling is an override worth naming,
// which is precisely the `10-bounded.conf` case from 2026-08-20.
memory_high: self
.memory
.as_ref()
.and_then(|m| m.systemd_high())
.unwrap_or_default(),
memory_max: self
.memory
.as_ref()
.and_then(|m| m.systemd_max())
.unwrap_or_default(),
// THE REACTION as this spec renders it. Empty ⇒ this spec declares none
// and the unit inherits the manager's `DefaultOOMPolicy=` — and then ANY
// drop-in `OOMPolicy=` is an override worth naming, exactly as an
// undeclared ceiling makes any drop-in ceiling one.
oom_policy: self
.oom_policy
.map(|p| p.as_systemd().to_string())
.unwrap_or_default(),
// THE RATE LIMIT, both halves. Empty ⇒ this spec declares none and the
// unit inherits the manager's defaults — so any drop-in number is an
// override worth naming, which is the case that actually bit.
start_limit_interval: self
.start_limit
.map(|l| l.interval_sec().to_string())
.unwrap_or_default(),
start_limit_burst: self
.start_limit
.filter(|l| l.interval_sec() != 0)
.map(|l| l.burst().to_string())
.unwrap_or_default(),
oom_score_adjust: self
.oom_score_adjust
.map(|a| a.value().to_string())
.unwrap_or_default(),
env: self
.environment
.iter()
.map(|e| (e.key.clone(), e.value.clone()))
.collect(),
}
}
/// The full `ExecStart=` value — `exec_path` followed by `exec_args`, each token
/// quoted by [`systemd_word`] so a value containing whitespace stays ONE argument.
///
/// See [`systemd_word`] for why joining with a bare space is not enough: systemd
/// splits `ExecStart=` into words exactly like a shell does.
pub fn exec_start(&self) -> String {
std::iter::once(self.exec_path.as_str())
.chain(self.exec_args.iter().map(String::as_str))
.map(systemd_word)
.collect::<Vec<_>>()
.join(" ")
}
}
/// **The fence that opens the OPERATOR-env block in a rendered unit.**
///
/// A comment, so systemd ignores it and so
/// [`crate::dropin::parse_service_directives`] — which skips comments — keeps seeing
/// the block's `Environment=` lines as ordinary declared env. It exists only so an
/// install can find, in the unit it previously wrote, the values THIS BOX was told, as
/// distinct from the values the product ships.
pub const OPERATOR_ENV_MARKER: &str = "# skidbladnir:operator-env BEGIN — set by `--install --env KEY=VALUE`, carried \
forward by the next install. Edit via --install, not by hand.";
/// The closing fence of the operator-env block.
pub const OPERATOR_ENV_END: &str = "# skidbladnir:operator-env END";
/// **Read an installed unit's OPERATOR-env block back out of its text.**
///
/// This is the seam that closes the drop-in's one genuine advantage. Before it, an
/// operator decision expressed as `--install --env KORP_SERVER_ADDR=0.0.0.0:50051` lived
/// only in that one argv: the next `--install` (or the next `--refresh`, or an
/// unattended watch tick) re-rendered the unit from the product's defaults and silently
/// put the service back on loopback. A `<unit>.service.d/addr.conf` did NOT have that
/// problem — which is precisely why somebody wrote one, and why it then survived an
/// uninstall and re-applied to an install that knew nothing about it.
///
/// Carrying the block forward makes the UNIT ITSELF the memory: one writer, visible to
/// `--unit`, removed by the uninstall that removes the unit, and audited by
/// `skidbladnir dropin-check` like every other declared line.
///
/// Parsing is deliberately literal — the lines between the two fences, in order — and a
/// missing/unterminated block yields nothing rather than a guess. Each value is the
/// [`systemd_word`] the renderer wrote, read back with [`systemd_unword`], so the value
/// carried forward is byte-for-byte the value the operator gave.
pub fn operator_env_from_unit_text(text: &str) -> Vec<(String, String)> {
let mut out = Vec::new();
let mut inside = false;
for raw in text.lines() {
let line = raw.trim();
if line.starts_with("# skidbladnir:operator-env BEGIN") {
inside = true;
continue;
}
if line.starts_with("# skidbladnir:operator-env END") {
break;
}
if !inside {
continue;
}
// A section header ends the block whatever the fence says — an unterminated
// block must not swallow the [Install] section.
if line.starts_with('[') {
break;
}
if let Some(kv) = line.strip_prefix("Environment=") {
if let Some((k, v)) = kv.split_once('=') {
out.push((k.trim().to_string(), systemd_unword(v)));
}
}
}
out
}
/// [`operator_env_from_unit_text`] against a unit on disk. A unit that is not there (a
/// FIRST install) is not an error — it is an empty carry-forward.
pub fn operator_env_from_unit_file(unit_path: &std::path::Path) -> Vec<(String, String)> {
match std::fs::read_to_string(unit_path) {
Ok(t) => operator_env_from_unit_text(&t),
Err(_) => Vec::new(),
}
}
/// PURE generator — system-scope unit. Back-compat shim for
/// `systemd_unit_scoped(spec, Scope::System)`. No root, no IO.
pub fn systemd_unit(spec: &SystemdServiceSpec) -> String {
systemd_unit_scoped(spec, Scope::System)
}
/// PURE generator — render the `.service` unit text from `spec` for `scope`. No
/// root, no IO; deterministic → unit-testable.
///
/// Mirrors the unit `run_install_systemd` builds for nornir, but every field is
/// driven by the spec. For [`Scope::User`] the `User=`/`Group=` lines are OMITTED
/// (a `systemctl --user` unit already runs as the invoking user — systemd rejects
/// those keys), and `[Install] WantedBy=` targets `default.target` (the user
/// manager's default) instead of the spec's system target.
/// The `Type=` values systemd accepts.
pub const SERVICE_TYPES: &[&str] = &[
"simple",
"exec",
"forking",
"oneshot",
"dbus",
"notify",
"notify-reload",
"idle",
];
/// **A service name every backend can use as a file name and a systemd unit name**
/// (stinky #17): 1–248 characters of `[A-Za-z0-9:_.@-]` (248 + `.service` = systemd's
/// 256-byte unit-name limit), not starting with `.` — so no `/`, no `..`, no
/// whitespace and no hidden file. Pure; shared with the neutral
/// [`crate::service::ServiceSpec::validate`].
pub fn check_service_name(name: &str) -> Result<(), String> {
let charset_ok = name
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, ':' | '_' | '.' | '@' | '-'));
if name.is_empty() || name.len() > 248 || name.starts_with('.') || !charset_ok {
return Err(format!(
"service name `{}` is not a valid unit name: use 1-248 of [A-Za-z0-9:_.@-], not starting with `.`",
name.escape_debug()
));
}
Ok(())
}
/// **An environment variable NAME every backend can express** (stinky #7):
/// `[A-Za-z_][A-Za-z0-9_]*`. A space or `=` in a key splits the assignment, and a
/// newline injects a line.
pub fn check_env_key(key: &str) -> Result<(), String> {
let mut chars = key.chars();
let ok = chars
.next()
.is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
&& chars.all(|c| c.is_ascii_alphanumeric() || c == '_');
if ok {
Ok(())
} else {
Err(format!(
"env key `{}` is not a variable name ([A-Za-z_][A-Za-z0-9_]*)",
key.escape_debug()
))
}
}
/// **A field rendered raw onto one line must not contain a control character**
/// (stinky #7) — a `\n`/`\r` would end the line and start a new directive.
pub fn check_no_control(what: &str, value: &str) -> Result<(), String> {
if value.chars().any(char::is_control) {
return Err(format!(
"{what} `{}` contains a control character (newline/CR/…), which would inject a line into the rendered unit or script",
value.escape_debug()
));
}
Ok(())
}
/// A user/group name: `[A-Za-z0-9_.][A-Za-z0-9_.-]*` (systemd's relaxed rule, minus
/// the characters that would split or inject).
pub fn check_account_name(what: &str, name: &str) -> Result<(), String> {
let mut chars = name.chars();
let ok = chars
.next()
.is_some_and(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.'))
&& chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-'));
if ok {
Ok(())
} else {
Err(format!(
"{what} `{}` is not a valid account name ([A-Za-z0-9_.][A-Za-z0-9_.-]*)",
name.escape_debug()
))
}
}
/// A unit named in `After=`/`Wants=`/`WantedBy=`: unit-name characters (plus `\` for
/// systemd's own `\x2d` escapes) — these lists are space-separated, so whitespace
/// would silently add a second unit.
pub fn check_unit_ref(what: &str, unit: &str) -> Result<(), String> {
let ok = !unit.is_empty()
&& unit
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, ':' | '_' | '.' | '@' | '-' | '\\'));
if ok {
Ok(())
} else {
Err(format!(
"{what} `{}` is not a unit name",
unit.escape_debug()
))
}
}
/// **One `Environment=` assignment, quoted the way systemd actually parses it.**
///
/// `Environment=` is NOT "the rest of the line". systemd splits the value on
/// whitespace into several assignments and strips unbalanced quotes, so
/// `Environment=CFG={"url":"http://x"}` reaches the process as a mangled `CFG={url:http`
/// — and a JSON payload, a `Description`-like sentence, or any path with a space is
/// silently corrupted. The unit file still *contains* the string, which is why reading
/// the rendered text proves nothing about what the service receives.
///
/// MEASURED 2026-08-05 on oden: a collector unit carrying its config as JSON in
/// `Environment=` restart-looped with `parse $COLLECTOR_CONFIG_JSON as JSON: key must be
/// a string at line 1 column 2`, four restarts deep, while the rendered unit looked
/// perfect.
///
/// The quoting itself lives in [`systemd_word`] — `ExecStart=` is word-split by the
/// same rules and shares the same writer.
pub fn environment_assignment(key: &str, value: &str) -> String {
format!("{key}={}", systemd_word(value))
}
/// **The ONE writer of a systemd-safe word** — the single place that decides whether a
/// value can be emitted bare or must be double-quoted, and how it is escaped.
///
/// Two directives in a unit file are word-split by systemd rather than taken as
/// "the rest of the line", and both used to hand-roll their own joining:
///
/// - `Environment=` — splits into several assignments (`environment_assignment`)
/// - `ExecStart=` — splits into argv, exactly like a shell
/// ([`SystemdServiceSpec::exec_start`])
///
/// They were the same defect twice. `Environment=` was fixed on 2026-08-05 after a
/// collector unit carrying JSON restart-looped four times behind a unit file that read
/// perfectly; `ExecStart=` was left rendering `format!("{path} {}", args.join(" "))`,
/// so `sh -c "sleep 3000"` reached the process as three arguments. PROVEN live against
/// a real `systemd --user` on oden 2026-08-07: the unit never reached a MainPID
/// (`tests/exec_verify_live.rs`). One writer now, so the next directive that needs this
/// cannot get a third opinion.
///
/// systemd's rule: a word may be double-quoted, and inside the quotes `\\` and `\"` are
/// the two escapes.
///
/// Both directives are C-unescaped by systemd (`EXTRACT_CUNESCAPE`), so a control
/// character is written as its escape (`\n`, `\r`, `\t`, `\xNN`) inside the quotes: a
/// literal newline would END the line and start a new directive (stinky #8), while the
/// escape reaches the process as the newline it was.
///
/// `%` is ALWAYS doubled: systemd runs specifier expansion on both directives, so a bare
/// `%s` reached a live process as `/bin/bash` (measured 2026-10-10). A neutral
/// [`crate::service::ServiceSpec`] value means the same bytes on every backend; nothing
/// in this crate passes a specifier through it.
///
/// Values containing none of the characters systemd treats specially are emitted
/// **unchanged** — quoting them would be correct but would churn every rendered unit in
/// the fleet for nothing. The predicate is what a value must not contain to be safe
/// bare: whitespace (which splits it), control characters, and the quote/escape
/// characters themselves.
///
/// NOT handled, deliberately: `$` (variable expansion in `ExecStart=` only — it is
/// literal in `Environment=`).
pub fn systemd_word(value: &str) -> String {
let value = value.replace('%', "%%");
// An empty word must be `""` — bare, it would vanish from the command line.
let needs_quoting = value.is_empty()
|| value
.chars()
.any(|c| c.is_whitespace() || c.is_control() || c == '"' || c == '\'' || c == '\\');
if !needs_quoting {
return value;
}
let mut out = String::with_capacity(value.len() + 2);
out.push('"');
for c in value.chars() {
match c {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if (c as u32) < 0x20 || c as u32 == 0x7f => {
out.push_str(&format!("\\x{:02x}", c as u32));
}
c => out.push(c),
}
}
out.push('"');
out
}
/// The inverse of [`systemd_word`] for ONE word as written by it: strip the quotes,
/// undo the escapes, and un-double `%`. A bare word is taken verbatim (only `%%`
/// folds). Used by the operator-env carry-forward, which must read back the exact
/// value it was given.
pub fn systemd_unword(word: &str) -> String {
let body = match word.strip_prefix('"').and_then(|w| w.strip_suffix('"')) {
Some(b) => b,
None => return word.replace("%%", "%"),
};
let mut out = String::with_capacity(body.len());
let mut it = body.chars();
while let Some(c) = it.next() {
if c != '\\' {
out.push(c);
continue;
}
match it.next() {
Some('n') => out.push('\n'),
Some('r') => out.push('\r'),
Some('t') => out.push('\t'),
Some('x') => {
let hex: String = it.by_ref().take(2).collect();
match u8::from_str_radix(&hex, 16) {
Ok(b) => out.push(b as char),
Err(_) => {
out.push_str("\\x");
out.push_str(&hex);
}
}
}
Some(other) => out.push(other),
None => out.push('\\'),
}
}
out.replace("%%", "%")
}
/// **RUNTIME PATHS THIS UNIT NAMES THAT BELONG TO A DIFFERENT ACCOUNT** — pure, so the
/// whole rule is testable against a passwd fixture with no host and no root.
///
/// A `/run/user/<uid>` path is not a configurable string: it is a directory logind creates
/// for ONE account, mode 0700, and a process running as anybody else cannot open anything
/// inside it. So a unit that says `User=korp` and `XDG_RUNTIME_DIR=/run/user/1000` is not
/// mis-tuned, it is FALSE — and it fails at run time as a permission error naming an
/// account the reader of the unit never mentioned.
///
/// **A real defect, not a hypothetical.** korp derives its `DOCKER_HOST` /
/// `XDG_RUNTIME_DIR` / `DBUS_SESSION_BUS_ADDRESS` from the run-as account's uid, and fell
/// back to the INVOKING process's euid when that account did not exist yet — which on a
/// first `sudo korp-server --install --system` is always. The unit that came out said
/// `User=korp` and pointed all three at `/run/user/1000`, the human's own runtime dir.
/// Caught by reading the file on 2026-08-15; nothing in this crate objected, and nothing
/// would have objected for the next product to do it.
///
/// Returns one message per offending `Environment=` line, empty when the unit is honest.
/// Scope matters: a `--user` unit renders no `User=` and runs as whoever's manager loaded
/// it, so there is no second account to be wrong about and the rule does not apply.
///
/// `passwd_path` is injected for the reason the rest of this module injects it: the rule is
/// decided against a file's CONTENTS, so a test must be able to supply them.
pub fn foreign_runtime_dir_env_in(
spec: &SystemdServiceSpec,
scope: Scope,
passwd_path: &std::path::Path,
) -> Vec<String> {
if scope.is_user() {
return Vec::new();
}
// The account the unit actually runs as — the same `unwrap_or(name)` the renderer
// applies below, so the guard judges the `User=` line that will really be written.
let user = if spec.user.is_empty() {
spec.service_name.as_str()
} else {
spec.user.as_str()
};
let own_uid = lookup_system_uid_in(user, passwd_path);
let mut problems = Vec::new();
for EnvVar { key, value } in &spec.environment {
// Every uid-owned runtime path is `/run/user/<uid>`; the digits are taken as a
// whole run so `/run/user/1000` is never read as uid 1, 10 or 100.
let Some(rest) = value.split("/run/user/").nth(1) else {
continue;
};
let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
let Ok(named_uid) = digits.parse::<u32>() else {
continue;
};
match own_uid {
// The honest case, and the overwhelmingly common one.
Some(u) if u == named_uid => {}
Some(u) => problems.push(format!(
"`Environment={key}={value}` names /run/user/{named_uid}, but this unit runs \
as `{user}` (uid {u}). That directory belongs to another account and is mode \
0700 — `{user}` cannot open anything inside it, so the service fails with a \
permission error naming an account this unit never mentions. Derive the path \
from the RUN-AS account's uid, or declare nothing (see \
`ComponentSpec::with_env_opt`)."
)),
// The account does not exist yet — which is exactly when a caller is most
// likely to have substituted its own euid, so this is the case most worth
// catching rather than one to wave through.
None => problems.push(format!(
"`Environment={key}={value}` names /run/user/{named_uid}, but `{user}` has no \
entry in {} — so the uid in that path cannot be this unit's, and is almost \
certainly the uid of whoever ran the install. Provision the account before \
rendering the unit, or declare nothing (see `ComponentSpec::with_env_opt`).",
passwd_path.display()
)),
}
}
problems
}
/// [`foreign_runtime_dir_env_in`] against the live `/etc/passwd`.
pub fn foreign_runtime_dir_env(spec: &SystemdServiceSpec, scope: Scope) -> Vec<String> {
foreign_runtime_dir_env_in(spec, scope, std::path::Path::new("/etc/passwd"))
}
pub fn systemd_unit_scoped(spec: &SystemdServiceSpec, scope: Scope) -> String {
let mut s = String::new();
// [Unit]
s.push_str("[Unit]\n");
/// Drop repeats, keeping the FIRST mention's position. Order is not cosmetic in
/// a unit file — it is the order a reader (and a diff) sees — so this must not
/// become a sort.
fn dedup_keep_order(v: &mut Vec<String>) {
let mut seen = std::collections::HashSet::new();
v.retain(|x| seen.insert(x.clone()));
}
// Description= and WorkingDirectory= are specifier-expanded: a literal `%` doubles.
s.push_str(&format!(
"Description={}\n",
spec.description.replace('%', "%%")
));
// `network-online.target` is a SYSTEM target: a `systemctl --user` unit cannot
// order against it (nornir's install path documents exactly this — the dev
// service "just starts with the session"), so it is added ONLY in system scope.
// Then any caller-supplied edges. Empty After=/Wants= lines are omitted (a
// user-scope unit with no deps carries neither), which is what makes a
// nornir/holger user service render byte-identical through this one renderer.
let mut after: Vec<String> = Vec::new();
let mut wants: Vec<String> = Vec::new();
if !scope.is_user() {
after.push("network-online.target".to_string());
wants.push("network-online.target".to_string());
}
after.extend(spec.after.iter().cloned());
wants.extend(spec.wants.iter().cloned());
// De-duplicate, keeping first-mention order. `network-online.target` is added
// unconditionally above AND is the single most likely thing a caller names
// explicitly; `ServiceSpec::needs` additionally pushes its dep onto BOTH lists.
// A spec that said `.after("network-online.target").needs("network-online.target")`
// therefore rendered `After=network-online.target network-online.target
// network-online.target` and `Wants=` it twice — observed in a REAL rendered unit
// inside a booted guest, 2026-08-03. systemd tolerates the repeat, which is exactly
// why it survived: a defect whose only symptom is a slightly stupid unit file has
// no failing test to find it. Fixed at the one place both lists are written,
// rather than by asking every caller to be careful.
dedup_keep_order(&mut after);
dedup_keep_order(&mut wants);
if !after.is_empty() {
s.push_str(&format!("After={}\n", after.join(" ")));
}
if !wants.is_empty() {
s.push_str(&format!("Wants={}\n", wants.join(" ")));
}
// THE RATE LIMIT the restart cadence below runs into. It is rendered HERE, in
// `[Unit]`, while its other half `RestartSec=` is a `[Service]` key — a split
// systemd imposes (`man systemd.unit` vs `man systemd.service`) and a fair part
// of why the two drifted apart for as long as they did. Undeclared ⇒ no lines,
// so the unit inherits `DefaultStartLimitBurst=`/`DefaultStartLimitIntervalSec=`
// and every unit written before this field existed is byte-identical.
//
// `StartLimitIntervalSec=0` is the documented DISABLE and is rendered on its own:
// pairing it with a burst would be writing a number that cannot mean anything.
if let Some(l) = &spec.start_limit {
s.push_str(&format!("StartLimitIntervalSec={}\n", l.interval_sec()));
if l.interval_sec() != 0 {
s.push_str(&format!("StartLimitBurst={}\n", l.burst()));
}
}
s.push('\n');
// [Service]
s.push_str("[Service]\n");
s.push_str(&format!("Type={}\n", spec.service_type));
if spec.remain_after_exit {
s.push_str("RemainAfterExit=yes\n");
}
// User=/Group= are invalid in a `systemctl --user` unit (it runs as you).
if !scope.is_user() {
s.push_str(&format!("User={}\n", spec.user));
s.push_str(&format!("Group={}\n", spec.group_name()));
// ADDITIONAL groups, beyond the one primary `Group=`. This is the only
// non-root way for a `User=`-confined service to open a resource owned by
// another account's group — the podman API socket being the case that
// forced it (see `ServiceSpec::supplementary_group`). Empty ⇒ no line, so
// every existing unit renders byte-for-byte as before.
//
// Scoped with `User=`/`Group=` deliberately: a `systemctl --user` unit runs
// as the caller with the caller's own groups already applied, and systemd
// rejects the directive there for the same reason it rejects `User=`.
if !spec.supplementary_groups.is_empty() {
s.push_str(&format!(
"SupplementaryGroups={}\n",
spec.supplementary_groups.join(" ")
));
}
}
s.push_str(&format!(
"WorkingDirectory={}\n",
spec.working_dir.replace('%', "%%")
));
// Inline Environment= lines (env file is referenced separately).
for e in &spec.environment {
s.push_str(&format!(
"Environment={}\n",
environment_assignment(&e.key, &e.value)
));
}
// …then the OPERATOR block, fenced by a marker so the next install can read its own
// deployment's decisions back out of the unit it wrote and carry them forward. Last
// assignment of a variable wins in systemd, so an operator value also OVERRIDES the
// product default of the same key — which is the whole point of the axis.
if !spec.operator_environment.is_empty() {
s.push_str(OPERATOR_ENV_MARKER);
s.push('\n');
for e in &spec.operator_environment {
s.push_str(&format!(
"Environment={}\n",
environment_assignment(&e.key, &e.value)
));
}
s.push_str(OPERATOR_ENV_END);
s.push('\n');
}
if let Some(loc) = env_file_location(spec, scope) {
s.push_str(&format!("EnvironmentFile={}\n", loc.unit_value()));
}
s.push_str(&format!("ExecStart={}\n", spec.exec_start()));
// The MARKER CHANNEL. Both streams to ONE file so the driver reads what the
// service said in arrival order — the same interleaving `draupnir::exe::ExeBoot`
// gives a child process's two pipes. Absent by default ⇒ neither line ⇒ every
// unit rendered before this field existed is byte-identical. NOT quoted through
// `systemd_word`: this directive is not word-split, and a path that would need
// quoting is refused by `validate()` instead of being silently mangled.
if let Some(f) = &spec.stdout_file {
s.push_str(&format!("StandardOutput=append:{f}\n"));
s.push_str(&format!("StandardError=append:{f}\n"));
}
s.push_str(&format!("Restart={}\n", spec.restart.as_systemd()));
s.push_str(&format!("RestartSec={}\n", spec.restart_sec));
// THE RESOURCE CEILING, written immediately after `Restart=` because the two are
// one mechanism: `MemoryMax=` is what turns "this cgroup grew without bound and
// the kernel picked a victim somewhere on the box" into "this cgroup was killed
// and Restart= brought it back in RestartSec= seconds". `MemoryHigh=` is the
// gentler half — throttle and reclaim, the service gets SLOW rather than dead.
//
// NOT inside the `if !scope.is_user()` block above: a `systemctl --user` unit gets
// its own delegated cgroup and DOES honour both directives (MEASURED, systemd 259
// — see `SystemdServiceSpec::memory`), unlike `User=`/`SupplementaryGroups=`.
//
// Nothing declared ⇒ neither line, so every unit rendered before this field
// existed is byte-identical.
if let Some(m) = &spec.memory {
if let Some(high) = m.systemd_high() {
s.push_str(&format!("MemoryHigh={high}\n"));
}
if let Some(max) = m.systemd_max() {
s.push_str(&format!("MemoryMax={max}\n"));
}
}
// …and THE REACTION, immediately after the ceiling, because the three directives
// are one sentence read top to bottom: `MemoryMax=` is the point a process is
// reaped, `OOMPolicy=` is what that does to the UNIT, and `Restart=` above is
// whether it comes back. Splitting them across the file would let a reader take
// the ceiling for the whole story — which is exactly what happened before this
// field existed, when the reaction was silently the box's `DefaultOOMPolicy=`.
//
// Also outside the `!scope.is_user()` block: a `--user` unit has its own manager
// and its own OOMPolicy (`user@1000.service` reports one). Nothing declared ⇒ no
// line, so every unit rendered before this field existed is byte-identical.
// WHICH unit the kernel picks when the BOX is short — the third OOM directive,
// rendered beside the reaction because they are read together. Undeclared ⇒ no
// line ⇒ the manager's own level (measured 0).
if let Some(a) = spec.oom_score_adjust {
s.push_str(&format!("OOMScoreAdjust={}\n", a.value()));
}
if let Some(p) = spec.oom_policy {
s.push_str(&format!("OOMPolicy={}\n", p.as_systemd()));
}
if spec.no_new_privileges {
s.push_str("NoNewPrivileges=true\n");
}
s.push('\n');
// [Install]
s.push_str("[Install]\n");
let wanted_by = if scope.is_user() {
"default.target"
} else {
spec.wanted_by.as_str()
};
s.push_str(&format!("WantedBy={}\n", wanted_by));
s
}
/// The per-user unit dir, relative to the account's home. One spelling, shared by the
/// install, the uninstall and [`env_file_location`] (whose unit text names it via `%h`).
pub const USER_UNIT_DIR_REL: &str = ".config/systemd/user";
/// **Where a spec's env file lives in one scope** — the ONE answer the renderer
/// ([`systemd_unit_scoped`]), the install and the uninstall share.
///
/// They used to answer separately, and disagreed: in user scope the install relocated
/// the file next to the unit while the unit kept naming the spec's `/etc` path, so a
/// `--user` service either failed to start (file missing) or read a stale root-owned
/// copy.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EnvFileLocation {
/// System scope: the declared absolute path, verbatim.
Absolute(PathBuf),
/// User scope: this file name inside the per-user unit dir
/// (`~/.config/systemd/user/<file>`), so an unprivileged install never touches `/etc`.
UserUnitDir(String),
}
impl EnvFileLocation {
/// The `EnvironmentFile=` value. User scope names the file through `%h` (the
/// service manager's home), so the unit text stays host-independent; `%` in a path
/// is escaped because systemd expands specifiers in this directive.
pub fn unit_value(&self) -> String {
match self {
EnvFileLocation::Absolute(p) => p.display().to_string().replace('%', "%%"),
EnvFileLocation::UserUnitDir(f) => {
format!("%h/{USER_UNIT_DIR_REL}/{}", f.replace('%', "%%"))
}
}
}
/// The on-disk path, given the unit dir the unit itself lives in.
pub fn path_in(&self, unit_dir: &Path) -> PathBuf {
match self {
EnvFileLocation::Absolute(p) => p.clone(),
EnvFileLocation::UserUnitDir(f) => unit_dir.join(f),
}
}
/// The path relative to the backend root a [`crate::service::RenderedFile`] uses:
/// absolute for system scope, `$HOME`-relative for user scope.
pub fn rendered_path(&self) -> String {
match self {
EnvFileLocation::Absolute(p) => p.display().to_string(),
EnvFileLocation::UserUnitDir(f) => format!("{USER_UNIT_DIR_REL}/{f}"),
}
}
}
/// Resolve [`SystemdServiceSpec::env_file`] for `scope` — `None` when the spec declares
/// no env file. See [`EnvFileLocation`].
pub fn env_file_location(spec: &SystemdServiceSpec, scope: Scope) -> Option<EnvFileLocation> {
let ef = spec.env_file.as_deref()?;
Some(if scope.is_user() {
EnvFileLocation::UserUnitDir(
Path::new(ef)
.file_name()
.map(|f| f.to_string_lossy().into_owned())
.unwrap_or_else(|| format!("{}.env", spec.service_name)),
)
} else {
EnvFileLocation::Absolute(PathBuf::from(ef))
})
}
/// PURE generator — render the env-file body (`KEY=VALUE\n` per entry) from
/// `spec.environment` FOLLOWED BY `spec.secret_environment`. This is the generic
/// form of nornir's `/etc/nornir/nornir.env` body. The secret entries appear ONLY
/// here (the mode-0600 file), never in the world-readable unit. No root, no IO;
/// deterministic → unit-testable.
pub fn env_file(spec: &SystemdServiceSpec) -> String {
let mut s = String::new();
for e in &spec.environment {
s.push_str(&env_file_line(&e.key, &e.value));
}
// Operator values come after the defaults here too, so the file and the unit agree
// about which one wins.
for e in &spec.operator_environment {
s.push_str(&env_file_line(&e.key, &e.value));
}
// Secrets live in the private (0600) env file ONLY — this is the whole reason
// they are a separate axis from the inline `Environment=` lines.
for e in &spec.secret_environment {
s.push_str(&env_file_line(&e.key, &e.value));
}
s
}
/// One `KEY=VALUE` line of an `EnvironmentFile=`, quoted by systemd's own parser rules
/// (stinky #9). Bare, systemd strips quotes, eats backslashes, continues a line that
/// ends in `\` and ends the value at a newline — so a value that is not plainly safe is
/// double-quoted, with `"`, `\`, `` ` `` and `$` backslash-escaped (the four escapes
/// honoured inside double quotes). A newline is kept raw INSIDE the quotes, where the
/// parser keeps it. The same text is a valid POSIX-shell assignment.
pub fn env_file_line(key: &str, value: &str) -> String {
let plain = !value.is_empty()
&& value
.chars()
.all(|c| c.is_ascii_alphanumeric() || "_-./:@,+=%".contains(c));
if plain {
return format!("{key}={value}\n");
}
let mut v = String::with_capacity(value.len() + 2);
v.push('"');
for c in value.chars() {
if matches!(c, '"' | '\\' | '`' | '$') {
v.push('\\');
}
v.push(c);
}
v.push('"');
format!("{key}={v}\n")
}
/// Example constructor: how nornir's own service would be expressed as a spec.
/// NOT wired into nornir's binary (create-only) — it shows the mapping from
/// `run_install_systemd`'s hardcoded values onto the generic spec, and doubles as
/// a stable fixture for the tests.
pub fn nornir_spec(server_bin: &str, addr: &str) -> SystemdServiceSpec {
SystemdServiceSpec::new(
"nornir",
"nornir server",
server_bin,
"nornir",
"/home/nornir",
)
.with_env("HOME", "/home/nornir")
.with_env_file("/etc/nornir/nornir.env")
.with_restart(RestartPolicy::OnFailure)
// The env file carries the token + addr; surfaced here as env entries so the
// env-file generator emits them. (HOME stays an inline Environment= for the
// unit, matching nornir.)
.with_env("NORNIR_SERVER_ADDR", addr)
// Production drop-ins reverse-engineered from oden (ex-`bifrost`'s
// nornir.service.d/{cuda,path}.conf — the only operational knowledge that repo
// held that this spec didn't): the CUDA lib root the server dlopen's, and the
// PATH that puts /opt/nornir/bin ahead of the system dirs.
.with_env("NORNIR_CUDA_LIBS", "/opt/nornir/cuda")
.with_env("PATH", "/opt/nornir/bin:/usr/local/bin:/usr/bin:/bin")
}
/// Example constructor showing how **holger** would build a spec to reuse this
/// installer. NOT wired into holger's binary (create-only) — holger keeps its own
/// install command until INSTALL-4 swaps it over.
pub fn holger_spec(holger_bin: &str, data_dir: &str) -> SystemdServiceSpec {
SystemdServiceSpec::new(
"holger",
"Holger artifact repository",
holger_bin,
"_holger",
data_dir,
)
.with_arg("serve")
.with_arg("--config")
.with_arg("/etc/holger/holger.toml")
.after("nornir-server.service")
.with_env("HOLGER_DATA", data_dir)
.with_env_file("/etc/holger/holger.env")
.with_restart(RestartPolicy::OnFailure)
}
// ───────────────────────── privileged install (unix) ─────────────────────────
#[cfg(unix)]
unsafe extern "C" {
fn geteuid() -> u32;
/// Only the guard's own tests need this (they chown a fixture to the running
/// process's OWN ids — a permitted no-op that still exercises the whole walk
/// without root), so it is not compiled into the shipped library.
#[cfg(test)]
fn getegid() -> u32;
/// `lchown(2)` — chown that does **not** follow symlinks. [`chown_recursive`]
/// uses this (never `chown(2)`) so a symlink planted inside a service tree can
/// never redirect the re-own onto a file OUTSIDE that tree (e.g. a symlink
/// `…/.ssh/x -> /etc/shadow`), and so a dangling symlink cannot abort the walk.
fn lchown(path: *const std::os::raw::c_char, owner: u32, group: u32) -> i32;
/// `flock(2)` — the advisory lock [`lock_unit_dir`] holds over a unit directory.
fn flock(fd: i32, operation: i32) -> i32;
}
#[cfg(unix)]
const LOCK_EX: i32 = 2;
#[cfg(unix)]
const LOCK_NB: i32 = 4;
/// **Hold the unit directory** (stinky #15): an exclusive `flock(2)` on the directory
/// itself, held by the returned handle until it drops. Install and uninstall take it
/// before they read the old unit (operator-env carry-forward) and keep it through
/// write → reload → enable/start, so two concurrent runs serialize instead of one
/// reading the other's half-done state. On the directory, not on a lock file beside
/// the unit, so nothing extra is left in `/etc/systemd/system`.
#[cfg(unix)]
pub fn lock_unit_dir(dir: &std::path::Path) -> std::io::Result<std::fs::File> {
use std::os::fd::AsRawFd;
let f = std::fs::File::open(dir)?;
loop {
// SAFETY: a valid open fd; flock has no memory effects.
if unsafe { flock(f.as_raw_fd(), LOCK_EX) } == 0 {
return Ok(f);
}
let e = std::io::Error::last_os_error();
if e.kind() != std::io::ErrorKind::Interrupted {
return Err(e);
}
}
}
/// Non-blocking [`lock_unit_dir`]: `Ok(None)` when another holder has it.
#[cfg(unix)]
pub fn try_lock_unit_dir(dir: &std::path::Path) -> std::io::Result<Option<std::fs::File>> {
use std::os::fd::AsRawFd;
let f = std::fs::File::open(dir)?;
// SAFETY: as in lock_unit_dir.
if unsafe { flock(f.as_raw_fd(), LOCK_EX | LOCK_NB) } == 0 {
return Ok(Some(f));
}
let e = std::io::Error::last_os_error();
if e.kind() == std::io::ErrorKind::WouldBlock {
Ok(None)
} else {
Err(e)
}
}
// ───────────────────── chown blast-radius guard (INCIDENT 2026-07-22) ─────────
//
// A root-run installer must NEVER be able to re-own a path that is not strictly
// the service's own home/state tree. This guard is the ONE place that decision
// lives (L5) — `chown_recursive`, `chown_recursive_under` and `ensure_system_user`
// all route through it, and consumers (dwarves, nornir, holger, modgunn) CONSUME
// it rather than re-implementing a denylist.
//
// It is pure path/text logic (no syscalls, no root, works on every platform) so it
// is fully unit-testable, and it is DEPENDENCY-INJECTED (`guard_chown_target_in`
// takes the passwd file and the invoking user's home) so tests drive it against
// temp fixtures instead of the live system.
/// The minimum number of path components a chown target must have. `/` (0) and
/// `/home`, `/usr`, `/var`, `/srv`, … (1) are therefore always refused: a service
/// tree is at least two levels deep (`/home/<svc>`, `/var/lib/<svc>`).
pub const CHOWN_MIN_COMPONENTS: usize = 2;
/// Absolute paths that are NEVER a valid chown target, whatever the caller says.
/// (Both the directory itself and — for the virtual filesystems — everything under
/// it.) Kept as data so the refusal set is greppable and testable.
pub const CHOWN_DENY_ROOTS: &[&str] = &[
"/", "/bin", "/boot", "/dev", "/etc", "/home", "/lib", "/lib32", "/lib64", "/libx32", "/media",
"/mnt", "/opt", "/proc", "/root", "/run", "/sbin", "/srv", "/sys", "/tmp", "/usr", "/var",
];
/// Virtual/kernel filesystems: refused as a target AND as an ancestor of a target.
const CHOWN_DENY_SUBTREES: &[&str] = &["/proc", "/sys", "/dev"];
/// WHY a chown target was refused — a named reason, so a caller (and a test) can
/// assert on the specific defect instead of string-matching an error message.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ChownRefusal {
/// The path is not absolute (a relative path resolves against the process CWD —
/// under systemd `WorkingDirectory=/` that is the filesystem root).
NotAbsolute,
/// The path is a system directory that is never a service tree (see
/// [`CHOWN_DENY_ROOTS`]).
SystemDir,
/// Fewer than [`CHOWN_MIN_COMPONENTS`] path components.
TooShallow { components: usize },
/// The caller declared an allowlisted root and the target is not under it.
OutsideAllowRoot { allow_root: std::path::PathBuf },
/// The target IS, is an ANCESTOR of, or lives INSIDE the invoking user's
/// `$HOME`. `sudo` PRESERVES `HOME`, so a root-run installer that derives a path
/// from `$HOME` would re-own the human's dotfiles and private SSH key — which is
/// exactly what happened on 2026-07-22 (`~/.bashrc`, `~/.ssh/id_ed25519`,
/// `~/.cache`). The containment test is SYMMETRIC on purpose: `~/.ssh` is not an
/// ancestor of `~`, and refusing only ancestors would have let it through.
InvokingUserHome { home: std::path::PathBuf },
/// The target is the home directory of a DIFFERENT account than the one being
/// chowned to.
ForeignUserHome { user: String, uid: u32 },
/// The target CONTAINS another account's home directory (e.g. `/home`, or `/`),
/// so a recursive re-own would swallow that account's files.
ContainsUserHome {
user: String,
home: std::path::PathBuf,
},
/// The target lives INSIDE another account's home directory (e.g.
/// `/home/alice/.ssh`) — that account's territory, never a service tree.
InsideUserHome {
user: String,
home: std::path::PathBuf,
},
}
impl std::fmt::Display for ChownRefusal {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ChownRefusal::NotAbsolute => write!(f, "path is not absolute"),
ChownRefusal::SystemDir => write!(f, "path is a system directory"),
ChownRefusal::TooShallow { components } => write!(
f,
"path has only {components} component(s); at least {CHOWN_MIN_COMPONENTS} required"
),
ChownRefusal::OutsideAllowRoot { allow_root } => {
write!(
f,
"path is outside the allowlisted root {}",
allow_root.display()
)
}
ChownRefusal::InvokingUserHome { home } => {
write!(
f,
"path is (or contains) the invoking user's home {}",
home.display()
)
}
ChownRefusal::ForeignUserHome { user, uid } => {
write!(
f,
"path is the home directory of account `{user}` (uid {uid})"
)
}
ChownRefusal::ContainsUserHome { user, home } => write!(
f,
"path contains account `{user}`'s home directory {}",
home.display()
),
ChownRefusal::InsideUserHome { user, home } => write!(
f,
"path is inside account `{user}`'s home directory {}",
home.display()
),
}
}
}
/// A REFUSED chown — the named error [`guard_chown_target`] returns instead of
/// re-owning a dangerous path. Carries the offending path and the [`ChownRefusal`]
/// reason so callers/tests can match precisely.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ChownRefused {
pub path: std::path::PathBuf,
pub reason: ChownRefusal,
}
impl std::fmt::Display for ChownRefused {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"REFUSING to chown {}: {} — a service install may only re-own its own \
home/state tree",
self.path.display(),
self.reason
)
}
}
impl std::error::Error for ChownRefused {}
/// Lexically normalise an absolute path (drop `.`, fold `..`, drop trailing `/`).
/// Purely syntactic — no filesystem access, so it is safe to call on paths that do
/// not exist and cannot itself be tricked into a syscall.
pub(crate) fn normalize_abs(p: &std::path::Path) -> Option<std::path::PathBuf> {
use std::path::{Component, PathBuf};
if !p.is_absolute() {
return None;
}
let mut out = PathBuf::from("/");
for c in p.components() {
match c {
Component::Normal(s) => out.push(s),
Component::ParentDir => {
out.pop();
}
Component::CurDir | Component::RootDir | Component::Prefix(_) => {}
}
}
Some(out)
}
/// Number of real (`Normal`) components in a normalised absolute path.
pub(crate) fn path_components(p: &std::path::Path) -> usize {
p.components()
.filter(|c| matches!(c, std::path::Component::Normal(_)))
.count()
}
/// Parse `(user, uid, home)` rows out of a passwd-format file. Rows whose home is
/// missing/relative are skipped. Never fails: an unreadable passwd file yields no
/// rows (the static denylist still applies).
fn passwd_homes(passwd_path: &std::path::Path) -> Vec<(String, u32, std::path::PathBuf)> {
std::fs::read_to_string(passwd_path)
.unwrap_or_default()
.lines()
.filter_map(|l| {
let f: Vec<&str> = l.split(':').collect();
if f.len() < 6 {
return None;
}
let uid: u32 = f[2].parse().ok()?;
let home = normalize_abs(std::path::Path::new(f[5]))?;
Some((f[0].to_string(), uid, home))
})
.collect()
}
/// Look up an existing account's uid from `/etc/passwd` WITHOUT creating it — the
/// read-only counterpart to [`ensure_system_user`]. `uninstall --purge` uses it to
/// tell [`guarded_remove_dir_all`] which account OWNS the data tree, so the guard
/// treats that account's own home as allowed rather than a foreign home to refuse.
/// `None` when the account does not exist.
pub fn lookup_system_uid(user: &str) -> Option<u32> {
lookup_system_uid_in(user, std::path::Path::new("/etc/passwd"))
}
/// Dependency-injected [`lookup_system_uid`] — reads `passwd_path` (a fixture in
/// tests), never the live system. Reuses the ONE passwd parser.
pub fn lookup_system_uid_in(user: &str, passwd_path: &std::path::Path) -> Option<u32> {
passwd_homes(passwd_path)
.into_iter()
.find(|(name, _, _)| name == user)
.map(|(_, uid, _)| uid)
}
/// The `(uid, gid)` an existing account's files must carry — what re-owning a
/// service tree needs, and what [`lookup_system_uid`] alone cannot answer.
///
/// # Why this exists
///
/// A system-scope install runs as **root** and provisions the product tree under the
/// SERVICE user's home. Without the gid there is no way to hand that tree over, so
/// the tree stays root-owned inside an account that is supposed to own it. MEASURED
/// on oden 2026-08-04: `/home/korp/.korp/{data,log,software,.refresh}` were all
/// `root:root` inside a home owned by `korp` — 26 paths — because the refresh path
/// created them as root and no caller could name the gid to give them back.
///
/// `None` when the account does not exist. Reads the same passwd rows as
/// [`lookup_system_uid_in`]; the gid is field 4, which the `(name, uid, home)` parser
/// deliberately drops.
pub fn lookup_system_ids(user: &str) -> Option<(u32, u32)> {
lookup_system_ids_in(user, std::path::Path::new("/etc/passwd"))
}
/// Dependency-injected [`lookup_system_ids`] — reads `passwd_path` (a fixture in
/// tests), never the live system.
pub fn lookup_system_ids_in(user: &str, passwd_path: &std::path::Path) -> Option<(u32, u32)> {
std::fs::read_to_string(passwd_path)
.unwrap_or_default()
.lines()
.find_map(|l| {
let f: Vec<&str> = l.split(':').collect();
if f.len() < 4 || f[0] != user {
return None;
}
Some((f[2].parse().ok()?, f[3].parse().ok()?))
})
}
/// Is this (already normalised) path a system directory — one of
/// [`CHOWN_DENY_ROOTS`], or inside `/proc`, `/sys`, `/dev`? The ONE denylist, shared
/// by the chown/delete guard and by [`crate::uninstall::DataRoot`] validation, so a
/// data root and a chown target cannot disagree about what `/etc` is.
pub fn is_system_dir(norm: &std::path::Path) -> bool {
CHOWN_DENY_ROOTS
.iter()
.any(|d| norm == std::path::Path::new(d))
|| CHOWN_DENY_SUBTREES
.iter()
.any(|d| norm.starts_with(std::path::Path::new(d)))
}
/// Every `(user, uid, home)` row in a passwd-format file, homes normalised — the ONE
/// parser, exposed so [`crate::uninstall::DataRoot`] validates against the same rows
/// the chown/delete guard reads.
pub fn passwd_homes_of(passwd_path: &std::path::Path) -> Vec<(String, u32, std::path::PathBuf)> {
passwd_homes(passwd_path)
}
/// The HOME of the account with this uid, from `/etc/passwd` — the by-uid twin of
/// [`lookup_system_home`], through the SAME parser.
///
/// This is how a `systemctl --user` unit finds the directory it belongs in:
/// a user unit lives in the home of whoever owns the user bus it is installed on,
/// i.e. the CALLER, which is a uid — not a name off a spec, and not `$HOME`, which is
/// whatever the environment last said (under `sudo`, root's).
pub fn home_of_uid(uid: u32) -> Option<std::path::PathBuf> {
home_of_uid_in(uid, std::path::Path::new("/etc/passwd"))
}
/// Dependency-injected [`home_of_uid`] — reads `passwd_path` (a fixture in tests),
/// never the live system.
pub fn home_of_uid_in(uid: u32, passwd_path: &std::path::Path) -> Option<std::path::PathBuf> {
passwd_homes(passwd_path)
.into_iter()
.find(|(_, u, _)| *u == uid)
.map(|(_, _, home)| home)
}
/// Look up an existing account's HOME from `/etc/passwd`. Note this is NOT where a
/// product's data lives — that is the ONE declared
/// [`DataRoot`](crate::uninstall::DataRoot), the same whoever owns the process. This
/// is only for the install-time home. `None` when the account does not exist or its
/// home is missing/relative.
pub fn lookup_system_home(user: &str) -> Option<std::path::PathBuf> {
lookup_system_home_in(user, std::path::Path::new("/etc/passwd"))
}
/// Dependency-injected [`lookup_system_home`] — reads `passwd_path` (a fixture in
/// tests), never the live system. Reuses the ONE passwd parser.
pub fn lookup_system_home_in(
user: &str,
passwd_path: &std::path::Path,
) -> Option<std::path::PathBuf> {
passwd_homes(passwd_path)
.into_iter()
.find(|(name, _, _)| name == user)
.map(|(_, _, home)| home)
}
/// The effective uid of the running process — the `uid` a per-user
/// ([`Scope::User`]) delete passes to [`guarded_remove_dir_all`], since a user-scope
/// service's data is owned by the caller. Public so a consumer never has to pull
/// `libc` (or guess `0`) just to call the guarded primitive.
#[cfg(unix)]
pub fn current_euid() -> u32 {
unsafe { geteuid() }
}
/// **The home of the account this process is RUNNING AS — out of passwd, by euid.**
/// The ONE answer to "where does this process's own state live", for every caller that
/// used to read `$HOME`.
///
/// `$HOME` is the environment's opinion: a parent sets it, `sudo` leaves the invoking
/// user's in place, a systemd unit gets it from passwd anyway, and a scrubbed unit
/// environment has none. passwd is what systemd itself resolved `User=` through, so it
/// is the same answer with none of the ways to be wrong. `$HOME` survives ONLY as the
/// last rung, for a uid the local passwd file does not carry (LDAP/SSSD) — a real case,
/// but not the authority.
///
/// A path resolved this way does not move when the environment moves; that is the whole
/// property. Callers that need it: the `systemctl --user` unit directory
/// (`~/.config/systemd/user`), and a product's own state root.
#[cfg(unix)]
pub fn caller_home() -> Option<std::path::PathBuf> {
caller_home_in(
current_euid(),
std::path::Path::new("/etc/passwd"),
std::env::var_os("HOME"),
)
}
/// Dependency-injected [`caller_home`] — every input explicit, so a test drives the
/// passwd-wins-over-`$HOME` precedence without touching the process environment or the
/// live passwd file. Reuses the ONE passwd parser.
pub fn caller_home_in(
uid: u32,
passwd_path: &std::path::Path,
home_env: Option<std::ffi::OsString>,
) -> Option<std::path::PathBuf> {
home_of_uid_in(uid, passwd_path).or_else(|| {
home_env
.map(std::path::PathBuf::from)
.filter(|h| h.is_absolute() && path_components(h) >= 1)
})
}
// ───────────────── WHAT IS INSTALLED — the ONE scope detector ─────────────────
/// **What is actually installed, at which scope, running as whom.** The read that must
/// precede every scope decision: an uninstall, a refresh and a status all have to know
/// which service exists before they can act on it, and none of them may take the answer
/// from a flag.
///
/// # Why this is one type and not three inline `exists()` pairs
///
/// The detection was written inline in [`remove_systemd_scoped_with`] and read nowhere
/// else, so [`crate::install::refresh_product`] guessed (fixed in `eb5424f`) and korp's
/// `--uninstall` ran its OWN eraser against a FLAG-derived root after this path had
/// already refused — two answers to "which install is this", one of them a constant.
/// MEASURED on oden 2026-08-04: `--uninstall --system --run-as alice` resolved
/// `/home/korp/.korp` while the service had written 118 GB into `/home/alice/.korp`,
/// and reported a clean uninstall over all of it.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct InstalledUnits {
/// The system unit file (`/etc/systemd/system/<unit>`), when it exists.
pub system: Option<std::path::PathBuf>,
/// The caller's per-user unit file (`~/.config/systemd/user/<unit>`), when it exists.
/// The home comes from [`caller_home`], never `$HOME`.
pub user: Option<std::path::PathBuf>,
/// The account the SYSTEM unit declares in `User=`, read out of the installed unit
/// text. `None` when there is no system unit, or it names no user (then systemd runs
/// it as root). This is the account whose home holds the data — the fact an operator
/// needs in order to know what a wrong-scope uninstall would have missed.
pub system_user: Option<String>,
}
impl InstalledUnits {
/// Nothing is installed at either scope. The outcome an uninstall must REPORT as
/// "nothing installed" rather than return as a quiet success.
pub fn nothing_installed(&self) -> bool {
self.system.is_none() && self.user.is_none()
}
/// Installed at BOTH scopes — genuinely ambiguous, so the caller's explicit scope
/// is the tiebreak (and gets said out loud).
pub fn ambiguous(&self) -> bool {
self.system.is_some() && self.user.is_some()
}
/// **The scope to ACT in.** What is installed decides; `requested` is consulted only
/// when the answer is genuinely ambiguous or there is nothing installed at all (then
/// it is the context's natural scope). A single install at one scope is never
/// overridden by a flag — that is the whole point of detecting.
pub fn resolve_scope(&self, requested: Scope, is_root: bool) -> Scope {
match (self.system.is_some(), self.user.is_some()) {
(true, false) => Scope::System,
(false, true) => Scope::User,
(true, true) => requested,
(false, false) => {
if is_root {
Scope::System
} else {
Scope::User
}
}
}
}
/// The unit file at `scope`, when one is installed there.
pub fn at(&self, scope: Scope) -> Option<&std::path::Path> {
match scope {
Scope::User => self.user.as_deref(),
Scope::System => self.system.as_deref(),
}
}
}
/// Detect [`InstalledUnits`] for `spec` against the live filesystem.
#[cfg(unix)]
pub fn detect_installed(spec: &SystemdServiceSpec) -> InstalledUnits {
detect_installed_in(
&spec.unit_file_name(),
std::path::Path::new("/"),
caller_home().as_deref(),
)
}
/// Dependency-injected [`detect_installed`] — `root` re-roots both unit directories (a
/// tempdir in tests, `/` live) and `caller_home` is passed in, so the three outcomes are
/// assertable without root, without systemd and without touching `$HOME`.
pub fn detect_installed_in(
unit: &str,
root: &std::path::Path,
caller_home: Option<&std::path::Path>,
) -> InstalledUnits {
let sys = root.join("etc/systemd/system").join(unit);
let usr = caller_home.map(|h| {
root.join(h.strip_prefix("/").unwrap_or(h))
.join(".config/systemd/user")
.join(unit)
});
let system = sys.exists().then_some(sys);
// `User=` off the INSTALLED unit text — what systemd read, not what a spec claims.
let system_user = system.as_ref().and_then(|p| {
std::fs::read_to_string(p).ok().and_then(|t| {
t.lines()
.filter_map(|l| l.trim().strip_prefix("User="))
.map(|u| u.trim().to_string())
.find(|u| !u.is_empty())
})
});
InstalledUnits {
system,
user: usr.filter(|p| p.exists()),
system_user,
}
}
/// Guard a chown target against the LIVE system (`/etc/passwd` + `$HOME`).
///
/// `uid` is the account the tree is being re-owned TO — that account's own home is
/// allowed, every other account's home (and any ancestor of one) is refused.
/// `allow_root`, when given, additionally pins the target to a subtree the caller
/// explicitly declared.
///
/// Returns [`ChownRefused`] — a NAMED error — instead of performing the chown.
pub fn guard_chown_target(
path: &std::path::Path,
uid: u32,
allow_root: Option<&std::path::Path>,
) -> Result<(), ChownRefused> {
let invoking_home = std::env::var_os("HOME").map(std::path::PathBuf::from);
guard_chown_target_in(
path,
uid,
allow_root,
std::path::Path::new("/etc/passwd"),
invoking_home.as_deref(),
)
}
/// Dependency-injected [`guard_chown_target`] — the seam tests drive against temp
/// fixtures (a fake passwd file and a decoy `$HOME`) instead of the live system.
/// Pure: no syscalls at all beyond reading `passwd_path`.
pub fn guard_chown_target_in(
path: &std::path::Path,
uid: u32,
allow_root: Option<&std::path::Path>,
passwd_path: &std::path::Path,
invoking_home: Option<&std::path::Path>,
) -> Result<(), ChownRefused> {
let refuse = |reason: ChownRefusal| {
Err(ChownRefused {
path: path.to_path_buf(),
reason,
})
};
// 1. Absolute only. A relative target resolves against the process CWD — and a
// systemd unit's `WorkingDirectory=/` makes that the filesystem ROOT.
let Some(norm) = normalize_abs(path) else {
return refuse(ChownRefusal::NotAbsolute);
};
// 2. Static system-directory denylist (`/`, `/home`, `/usr`, `/etc`, `/var`, …)
// plus everything under the kernel's virtual filesystems.
if CHOWN_DENY_ROOTS
.iter()
.any(|d| norm == std::path::Path::new(d))
|| CHOWN_DENY_SUBTREES
.iter()
.any(|d| norm.starts_with(std::path::Path::new(d)))
{
return refuse(ChownRefusal::SystemDir);
}
// 3. Depth floor — a service tree is never one component deep.
let n = path_components(&norm);
if n < CHOWN_MIN_COMPONENTS {
return refuse(ChownRefusal::TooShallow { components: n });
}
// 4. Caller-declared allowlisted root.
if let Some(root) = allow_root {
let Some(root) = normalize_abs(root) else {
return refuse(ChownRefusal::OutsideAllowRoot {
allow_root: root.to_path_buf(),
});
};
if !norm.starts_with(&root) {
return refuse(ChownRefusal::OutsideAllowRoot { allow_root: root });
}
}
let passwd = passwd_homes(passwd_path);
// 5. The INVOKING user's home. `sudo` PRESERVES `HOME=/home/<human>`, so any
// system-scope path that was derived from `$HOME` lands here and is refused.
//
// The containment test is SYMMETRIC — the target is refused whether it IS the
// home, CONTAINS it (`/home`, `/`), or lives INSIDE it (`~/.ssh`, `~/.cache`).
// On 2026-07-22 the paths that actually lost their owner were *inside*
// `$HOME`, so an ancestors-only test would still let the damage through.
//
// Exception: when `$HOME` is the home of the very account we are chowning TO
// (a service installing into its own home, or a `--user` scope install), it is
// that account's own tree and stays allowed.
if let Some(home) = invoking_home.and_then(normalize_abs) {
let is_target_accounts_own_home = passwd
.iter()
.any(|(_, entry_uid, h)| *entry_uid == uid && *h == home);
if path_components(&home) >= CHOWN_MIN_COMPONENTS
&& !is_target_accounts_own_home
&& (home.starts_with(&norm) || norm.starts_with(&home))
{
return refuse(ChownRefusal::InvokingUserHome { home });
}
}
// 6. Any OTHER account's home directory — as the target itself, as something the
// target would swallow on the way down (`/home`, `/`), or as the tree the
// target sits inside (`/home/alice/.ssh`). Symmetric for the same reason.
for (user, entry_uid, home) in passwd {
if entry_uid == uid || path_components(&home) < CHOWN_MIN_COMPONENTS {
continue;
}
if home == norm {
return refuse(ChownRefusal::ForeignUserHome {
user,
uid: entry_uid,
});
}
if home.starts_with(&norm) {
return refuse(ChownRefusal::ContainsUserHome { user, home });
}
if norm.starts_with(&home) {
return refuse(ChownRefusal::InsideUserHome { user, home });
}
}
Ok(())
}
/// WHY a purge-delete target was refused. An `uninstall --purge` recursively
/// DELETES the service's data tree — even less reversible than a chown — so its
/// guard is the chown guard ([`guard_chown_target_in`], every refusal of which is
/// also a delete refusal) PLUS one delete-only rule: a path any of whose
/// components is `.ssh` is KEY MATERIAL and is never wiped, even when it would
/// otherwise pass. That is what lets a purge "wipe data but keep keys" — it spares
/// both the human's `~/.ssh` and the service account's own deploy key
/// (`/home/dwarves/.ssh/id_ed25519`, which the forge clones private repos with).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DeleteRefusal {
/// Failed the shared ownership guard — see the wrapped [`ChownRefusal`].
Guard(ChownRefusal),
/// The path is (or contains a component named) `.ssh` — key material.
KeyMaterial,
}
impl std::fmt::Display for DeleteRefusal {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DeleteRefusal::Guard(r) => write!(f, "{r}"),
DeleteRefusal::KeyMaterial => write!(
f,
"path is (or contains) an `.ssh` key directory — key material, never data"
),
}
}
}
/// Pure, dependency-injected guard for a `--purge` delete target. Reuses
/// [`guard_chown_target_in`] verbatim (so keys, homes, system dirs and shallow
/// paths are all refused by the ONE authority) and adds the `.ssh` key-material
/// rule. No syscalls beyond reading `passwd_path`, so it is fully unit-testable
/// against a fixture passwd + decoy `$HOME`.
pub fn guard_delete_target_in(
path: &std::path::Path,
uid: u32,
allow_root: Option<&std::path::Path>,
passwd_path: &std::path::Path,
invoking_home: Option<&std::path::Path>,
) -> Result<(), DeleteRefusal> {
// Key material is never data: refuse any `.ssh` component up front. This is
// belt-and-suspenders — `~/.ssh` is already an `InsideUserHome` chown refusal —
// but it ALSO spares the service account's own `.ssh` (its own home is
// otherwise allowed), which the chown guard deliberately permits.
if let Some(norm) = normalize_abs(path) {
if norm.components().any(|c| c.as_os_str() == ".ssh") {
return Err(DeleteRefusal::KeyMaterial);
}
}
guard_chown_target_in(path, uid, allow_root, passwd_path, invoking_home)
.map_err(|e| DeleteRefusal::Guard(e.reason))
}
/// Guarded recursive delete — the primitive behind `uninstall --purge`. Refuses
/// via [`guard_delete_target_in`] (keys + every chown protection), never follows a
/// symlink out of the tree, and is IDEMPOTENT (a missing path is `Ok`). `uid` is
/// the account that OWNS the data tree (its own home stays allowed); `allow_root`
/// pins the delete to the service's declared home so even a caller bug can only
/// ever wipe inside it. Consumers (dwarves/nornir/holger) CALL this — the delete
/// decision lives here once (L5), never re-implemented.
#[cfg(unix)]
pub fn guarded_remove_dir_all(
path: &std::path::Path,
uid: u32,
allow_root: Option<&std::path::Path>,
) -> anyhow::Result<()> {
use anyhow::Context;
let invoking_home = std::env::var_os("HOME").map(std::path::PathBuf::from);
guard_delete_target_in(
path,
uid,
allow_root,
std::path::Path::new("/etc/passwd"),
invoking_home.as_deref(),
)
.map_err(|reason| {
anyhow::anyhow!(
"REFUSING to purge {}: {reason} — a purge wipes only the service's own \
data tree, never keys, a home, or a system directory",
path.display()
)
})?;
// Never follow a symlink out of the guarded tree; a missing path is a no-op. A
// directory (warehouse/state/registry) OR a plain file (e.g. a `token`) are both
// data to erase.
match std::fs::symlink_metadata(path) {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => {
Err(anyhow::Error::new(e).context(format!("stat purge target {}", path.display())))
}
Ok(md) if md.file_type().is_symlink() => anyhow::bail!(
"REFUSING to purge {}: it is a symlink — a purge never follows a link \
out of the data tree",
path.display()
),
Ok(md) if md.is_dir() => std::fs::remove_dir_all(path)
.with_context(|| format!("purge data dir {}", path.display())),
Ok(_) => std::fs::remove_file(path)
.with_context(|| format!("purge data file {}", path.display())),
}
}
/// The ONLY path by which `.ssh` key material is ever deleted — the explicit
/// `--wipe-identity` opt-in ([`crate::uninstall::UninstallOptions::wipe_identity`]).
///
/// ⚠ **This destroys the ssh deploy key registered on the private git remotes.** A
/// reinstall afterwards cannot clone anything until a human generates a new key and
/// registers it on **every** repo. It is never a side effect: [`guarded_remove_dir_all`]
/// hard-refuses any `.ssh` component, and this function is the single, separately
/// named, separately documented escape hatch.
///
/// Every OTHER protection still applies: the target is put through
/// [`guard_chown_target_in`] (system dirs, foreign homes, the invoking human's `$HOME`,
/// depth floor) and — unlike the data eraser — `root` is **mandatory**, so identity can
/// only ever be destroyed strictly INSIDE the service's own resolved state root. A
/// symlink is refused, and a missing path is `Ok(false)` (idempotent).
///
/// Returns whether anything was actually removed.
#[cfg(unix)]
pub fn wipe_identity_material(
path: &std::path::Path,
uid: u32,
root: &std::path::Path,
) -> anyhow::Result<bool> {
use anyhow::Context;
let (Some(norm), Some(root_norm)) = (normalize_abs(path), normalize_abs(root)) else {
anyhow::bail!(
"REFUSING to destroy identity at {}: both the target and the state root {} must be \
absolute",
path.display(),
root.display()
);
};
// MANDATORY containment — identity dies only inside the service's own tree.
if !norm.starts_with(&root_norm) || norm == root_norm {
anyhow::bail!(
"REFUSING to destroy identity at {}: it is not strictly inside the resolved state \
root {}",
norm.display(),
root_norm.display()
);
}
let invoking_home = std::env::var_os("HOME").map(std::path::PathBuf::from);
guard_chown_target_in(
&norm,
uid,
Some(&root_norm),
std::path::Path::new("/etc/passwd"),
invoking_home.as_deref(),
)
.map_err(|e| {
anyhow::anyhow!(
"REFUSING to destroy identity at {}: {} — even the identity opt-in never touches a \
home, a system directory or another account's tree",
norm.display(),
e.reason
)
})?;
match std::fs::symlink_metadata(&norm) {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(e) => {
Err(anyhow::Error::new(e).context(format!("stat identity target {}", norm.display())))
}
Ok(md) if md.file_type().is_symlink() => anyhow::bail!(
"REFUSING to destroy identity at {}: it is a symlink — never follow a link out of \
the state tree",
norm.display()
),
Ok(md) if md.is_dir() => {
std::fs::remove_dir_all(&norm)
.with_context(|| format!("destroy identity dir {}", norm.display()))?;
Ok(true)
}
Ok(_) => {
std::fs::remove_file(&norm)
.with_context(|| format!("destroy identity file {}", norm.display()))?;
Ok(true)
}
}
}
/// Remove a SYSTEM account's rows from passwd/group/shadow — the pure-Rust `userdel`
/// counterpart to [`create_system_user_in`], with the three files as params so it is
/// unit-testable against fixtures.
///
/// **Refuses** any account outside the system range `[100, 1000)` — root, a human
/// login and a nobody-style high uid are never removable this way — and refuses an
/// account that does not exist (returns `Ok(false)`, idempotent).
///
/// ⚠ This does NOT touch the account's home. holger's old uninstall did
/// `remove_dir_all("/home/holger")` and THEN `userdel`, destroying the ssh deploy key
/// twice over; the key material must be handled FIRST and deliberately (see
/// [`crate::uninstall::UninstallOptions::validate`]), which is why removing the home
/// is not this function's business.
#[cfg(unix)]
pub fn remove_system_user_in(
passwd_path: &std::path::Path,
group_path: &std::path::Path,
shadow_path: &std::path::Path,
user: &str,
) -> anyhow::Result<bool> {
use anyhow::Context;
let Some(uid) = lookup_system_uid_in(user, passwd_path) else {
return Ok(false); // no such account — idempotent
};
if !(100..1000).contains(&uid) {
anyhow::bail!(
"REFUSING to remove account `{user}` (uid {uid}): only system accounts in [100,1000) \
are removable — never root and never a human login"
);
}
let strip = |path: &std::path::Path| -> anyhow::Result<()> {
let Ok(body) = std::fs::read_to_string(path) else {
return Ok(()); // absent file (e.g. no shadow in a fixture) — nothing to do
};
let kept: Vec<&str> = body
.lines()
.filter(|l| l.split(':').next() != Some(user))
.collect();
let mut out = kept.join("\n");
if !out.is_empty() {
out.push('\n');
}
let mode = {
use std::os::unix::fs::PermissionsExt;
std::fs::metadata(path).map(|m| m.permissions().mode() & 0o7777)?
};
write_atomic(path, out.as_bytes(), mode)
.with_context(|| format!("rewrite {}", path.display()))
};
strip(passwd_path)?;
strip(group_path)?;
strip(shadow_path)?;
Ok(true)
}
/// Remove a system account from the live `/etc/{passwd,group,shadow}`. Root-only.
/// See [`remove_system_user_in`] for the refusals — and note that the account's HOME
/// (and therefore its ssh key) is deliberately NOT touched here.
#[cfg(unix)]
pub fn remove_system_user(user: &str) -> anyhow::Result<bool> {
remove_system_user_in(
std::path::Path::new("/etc/passwd"),
std::path::Path::new("/etc/group"),
std::path::Path::new("/etc/shadow"),
user,
)
}
/// The STANDARD uninstall data-erase: wipe a service home's contents EXCEPT its
/// identity — `.ssh` (the git deploy key) and any stored `token` (the full spared set
/// is [`crate::uninstall::IDENTITY_NAMES`]) — each delete behind
/// [`guarded_remove_dir_all`] (which refuses system dirs, real users' homes, symlinks,
/// and any `.ssh`). So a suite product's `uninstall` wipes its warehouse / state /
/// registry — a reinstall starts clean — while the service's SSH identity SURVIVES.
/// `home` is the service's declared `service_home`; `user` is the owning account (its
/// own home is the allowed tree). Returns `(data_items_wiped, identity_items_kept)`.
///
/// **This is the low-level sweep**, kept for the consumers that already call it. It
/// takes `home` VERBATIM — it does not consult, and cannot validate, the product's
/// declared data root, so a caller that passes a path its own resolver never uses
/// still wipes nothing (dwarves' and korp's silent no-op). The entry point every
/// caller should move to is [`crate::service::Lifecycle::uninstall`], which reads the
/// ONE declared [`crate::uninstall::DataRoot`] and returns an honest
/// [`crate::uninstall::UninstallReport`].
///
/// CHANGED 2026-07-27: a nested `token` now SURVIVES (it is identity, not data);
/// previously a `.nornir/` containing one was erased whole.
#[cfg(unix)]
pub fn purge_service_data_keep_keys(home: &std::path::Path, user: &str) -> (usize, usize) {
let mut owner = crate::uninstall::EraseOwner::for_service(Some(user));
owner.service_uid = lookup_system_uid(user).unwrap_or_else(current_euid);
let mut report = crate::uninstall::UninstallReport::new("service");
crate::uninstall::execute_state_erase(
&crate::uninstall::StatePlan::sweep(home),
&owner,
crate::uninstall::UninstallOptions::new(),
&mut report,
);
for (p, why) in &report.refused {
eprintln!(
"skidbladnir: uninstall left {} in place: {why}",
p.display()
);
}
(report.erased.len(), report.kept.len())
}
/// Write `contents` to `path` as a PRIVATE (mode 0600) file, closing the
/// write-then-chmod window a naïve `fs::write` + `set_permissions` leaves open:
/// the file is CREATED with 0600 (via `OpenOptions::mode`) so a secret is never,
/// even briefly, world-readable on disk. When the path already exists, the mode
/// bits are re-tightened to 0600 after truncation (open's `mode` is honoured only
/// on creation). This is the one place the lifecycle code persists a secret
/// (systemd env file / node key), so both callers reuse it (L5).
#[cfg(unix)]
pub fn write_private_file(path: &std::path::Path, contents: &[u8]) -> std::io::Result<()> {
write_atomic(path, contents, 0o600)
}
/// **Replace `path` atomically at exactly `mode`** (stinky #11/#15/#16) — the ONE
/// writer for units, env files and every rendered file:
/// a fresh temp file beside `path` is created `O_CREAT|O_EXCL` (so it is never a
/// planted symlink and never an existing inode) at `mode`, `fchmod`ed to `mode`
/// (the umask cannot loosen it), filled, `fsync`ed and `rename`d over `path`, and
/// the directory is `fsync`ed. A symlink AT `path` is replaced, not followed; a
/// reader sees the old file or the new one, never a torn one; a crash leaves the
/// old file; and the old inode (and any hard link to it) never sees the new bytes.
#[cfg(unix)]
pub fn write_atomic(path: &std::path::Path, contents: &[u8], mode: u32) -> std::io::Result<()> {
use std::io::Write;
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
use std::sync::atomic::{AtomicU64, Ordering};
static SEQ: AtomicU64 = AtomicU64::new(0);
let dir = match path.parent() {
Some(d) if !d.as_os_str().is_empty() => d,
_ => std::path::Path::new("."),
};
let name = path.file_name().ok_or_else(|| {
std::io::Error::new(std::io::ErrorKind::InvalidInput, "write_atomic: path has no file name")
})?;
let mut tmp_name = std::ffi::OsString::from(".");
tmp_name.push(name);
tmp_name.push(format!(".tmp.{}.{}", std::process::id(), SEQ.fetch_add(1, Ordering::Relaxed)));
let tmp = dir.join(tmp_name);
let result = (|| {
let mut f = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(mode)
.open(&tmp)?;
f.set_permissions(std::fs::Permissions::from_mode(mode))?;
f.write_all(contents)?;
f.sync_all()?;
drop(f);
std::fs::rename(&tmp, path)?;
std::fs::File::open(dir)?.sync_all()
})();
if result.is_err() {
let _ = std::fs::remove_file(&tmp);
}
result
}
/// Recursively `chown` a tree via libc — no `chown` subprocess. Public so a
/// consumer (nornir, holger, dwarves) that just created a service user with
/// [`ensure_system_user`] can re-own extra state dirs to it without hand-rolling
/// its own `chown` twin.
///
/// **GUARDED** (incident 2026-07-22): the target is first put through
/// [`guard_chown_target`], which HARD-REFUSES `/`, `/home`, `/usr`, `/etc`,
/// `/var`, any path shallower than [`CHOWN_MIN_COMPONENTS`], the invoking user's
/// `$HOME`, and any path that is (or contains) another account's home directory.
/// A refusal returns the named [`ChownRefused`] error and performs NO syscall at
/// all. Use [`chown_recursive_under`] to additionally pin the walk to a root the
/// caller declares.
///
/// The walk uses `lchown(2)` and `symlink_metadata`, so symlinks are re-owned but
/// never FOLLOWED: a link planted in the tree cannot redirect the chown outside it
/// (and a dangling link no longer aborts the walk).
#[cfg(unix)]
pub fn chown_recursive(path: &std::path::Path, uid: u32, gid: u32) -> anyhow::Result<()> {
guard_chown_target(path, uid, None)?;
chown_tree(path, path, uid, gid)
}
/// [`chown_recursive`] pinned to an explicit `allow_root`: the target must be
/// `allow_root` itself or live under it, and the walk can never leave that subtree.
/// This is the form a caller should reach for whenever it has an explicit service
/// root to name (the guard is then belt AND braces).
#[cfg(unix)]
pub fn chown_recursive_under(
allow_root: &std::path::Path,
path: &std::path::Path,
uid: u32,
gid: u32,
) -> anyhow::Result<()> {
guard_chown_target(path, uid, Some(allow_root))?;
chown_tree(allow_root, path, uid, gid)
}
/// The unguarded walk behind [`chown_recursive`] — private ON PURPOSE so the guard
/// cannot be bypassed. `root` is the subtree the walk may never leave.
#[cfg(unix)]
fn chown_tree(
root: &std::path::Path,
path: &std::path::Path,
uid: u32,
gid: u32,
) -> anyhow::Result<()> {
use anyhow::Context;
use std::os::unix::ffi::OsStrExt;
// Containment: every descended path stays inside `root` (a lexical check — the
// walk itself never follows a symlink, so it cannot be raced out of the tree).
let (Some(nroot), Some(npath)) = (normalize_abs(root), normalize_abs(path)) else {
anyhow::bail!("chown root/target must be absolute: {}", path.display());
};
if !npath.starts_with(&nroot) {
anyhow::bail!(
"chown {} escapes its allowed root {}",
npath.display(),
nroot.display()
);
}
let c = std::ffi::CString::new(path.as_os_str().as_bytes())
.with_context(|| format!("path has interior NUL: {}", path.display()))?;
// lchown — NEVER chown(2): a symlink must not redirect the re-own.
if unsafe { lchown(c.as_ptr(), uid, gid) } != 0 {
anyhow::bail!(
"chown {} failed: {}",
path.display(),
std::io::Error::last_os_error()
);
}
// Descend into REAL directories only (symlink_metadata, not metadata) — a
// symlinked directory is re-owned as a link and never walked through.
let is_real_dir = std::fs::symlink_metadata(path)
.map(|m| m.file_type().is_dir())
.unwrap_or(false);
if is_real_dir {
for entry in std::fs::read_dir(path).with_context(|| format!("read {}", path.display()))? {
chown_tree(root, &entry?.path(), uid, gid)?;
}
}
Ok(())
}
/// Ensure a dedicated **system service user** exists (idempotent) — the 100%-Rust
/// replacement for `useradd --system --shell /usr/sbin/nologin` that consumers
/// (nornir, holger, dwarfs) call instead of shelling `useradd` (dup-audit P1,
/// zero-shell charter). No subprocess: it allocates a free system id and appends
/// matching `/etc/group` + `/etc/passwd` + locked `/etc/shadow` entries via
/// [`create_system_user_in`], creates `home` and chowns it to the user.
///
/// Idempotent: if `user` already exists its `(uid, gid)` is returned and nothing
/// is written. Returns the user's `(uid, gid)` so the caller can re-own extra
/// state dirs with [`chown_recursive`]. Needs root (writes `/etc/passwd` etc.).
///
/// **GUARDED** (incident 2026-07-22): `home` is validated with
/// [`guard_chown_target`] BEFORE anything is created — a `home` of `/`, `/home`,
/// the invoking user's `$HOME`, or another account's home aborts the whole call
/// with [`ChownRefused`] and no user, directory or chown happens.
#[cfg(unix)]
pub fn ensure_system_user(user: &str, home: &str) -> anyhow::Result<(u32, u32)> {
use std::path::Path;
ensure_system_user_in(
Path::new("/etc/passwd"),
Path::new("/etc/group"),
Path::new("/etc/shadow"),
user,
home,
// `sudo` PRESERVES the invoking human's `HOME` — hand it to the guard so a
// service home that was derived from it is refused instead of re-owned.
std::env::var_os("HOME")
.map(std::path::PathBuf::from)
.as_deref(),
)
}
/// Dependency-injected [`ensure_system_user`] — the same create-user,
/// create-home, re-own sequence, but against explicit passwd/group/shadow files (and an
/// explicit `invoking_home` standing in for `$HOME`) so the GUARD and the ordering
/// are unit-testable against temp fixtures, without root and without mutating
/// process env. Mirrors [`create_system_user_in`], the pure user-creation half.
#[cfg(unix)]
pub fn ensure_system_user_in(
passwd_path: &std::path::Path,
group_path: &std::path::Path,
shadow_path: &std::path::Path,
user: &str,
home: &str,
invoking_home: Option<&std::path::Path>,
) -> anyhow::Result<(u32, u32)> {
use anyhow::Context;
use std::path::Path;
// REFUSE FIRST — before a user row, a mkdir or a single chown syscall. The
// service home must be an explicit, sufficiently deep, non-system, not-someone-
// else's-home absolute path. On a RE-install the account already exists, so its
// own uid is looked up first and its own home stays allowed (idempotency); for a
// fresh account the sentinel `u32::MAX` matches nothing, so every home already in
// passwd counts as foreign — the strict reading we want at creation time.
let claimed_uid = passwd_homes(passwd_path)
.into_iter()
.find(|(u, _, _)| u == user)
.map(|(_, uid, _)| uid)
.unwrap_or(u32::MAX);
guard_chown_target_in(
Path::new(home),
claimed_uid,
None,
passwd_path,
invoking_home,
)
.with_context(|| format!("service home for `{user}` is not a safe chown target"))?;
let (uid, gid) = create_system_user_in(passwd_path, group_path, shadow_path, user, home)?;
std::fs::create_dir_all(home).context("create service home")?;
// Pinned to the service home itself: the walk can never leave it.
chown_recursive_under(Path::new(home), Path::new(home), uid, gid).ok();
Ok((uid, gid))
}
/// Non-unix stub — system-user creation is linux-only (mirrors [`install_systemd`](crate::systemd::install_systemd)).
#[cfg(not(unix))]
pub fn ensure_system_user(user: &str, _home: &str) -> anyhow::Result<(u32, u32)> {
anyhow::bail!("creating the system user `{user}` is linux-only")
}
/// Pure-Rust system-user creation (the `useradd --system` replacement), with the
/// three system files as params so it is unit-testable against temp fixtures.
/// Idempotent: if `user` is already in passwd, returns its `(uid, gid)` and writes
/// nothing. Otherwise allocates the lowest id free in **both** passwd and group
/// within `[100, 1000)`, and appends a group, a passwd, and a (locked) shadow row.
///
/// Public so a consumer can drive it against an alternate root (e.g. a staging
/// tree, or a test fixture) rather than the live `/etc` files; [`ensure_system_user`]
/// is the convenience wrapper that targets `/etc/{passwd,group,shadow}` + the home dir.
#[cfg(unix)]
pub fn create_system_user_in(
passwd_path: &std::path::Path,
group_path: &std::path::Path,
shadow_path: &std::path::Path,
user: &str,
home: &str,
) -> anyhow::Result<(u32, u32)> {
use anyhow::{Context, anyhow};
use std::io::Write;
let passwd = std::fs::read_to_string(passwd_path).unwrap_or_default();
for l in passwd.lines() {
let f: Vec<&str> = l.split(':').collect();
if f.first() == Some(&user) && f.len() >= 4 {
return Ok((f[2].parse().unwrap_or(0), f[3].parse().unwrap_or(0)));
}
}
let used_in = |s: &str| -> std::collections::BTreeSet<u32> {
s.lines()
.filter_map(|l| l.split(':').nth(2).and_then(|v| v.parse::<u32>().ok()))
.filter(|&n| (100..1000).contains(&n))
.collect()
};
let used_uid = used_in(&passwd);
let group = std::fs::read_to_string(group_path).unwrap_or_default();
let used_gid = used_in(&group);
let id = (100u32..1000)
.find(|i| !used_uid.contains(i) && !used_gid.contains(i))
.ok_or_else(|| anyhow!("no free system uid/gid in [100,1000) for `{user}`"))?;
let append = |path: &std::path::Path, line: String| -> anyhow::Result<()> {
let mut f = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)
.with_context(|| format!("open {}", path.display()))?;
writeln!(f, "{line}").with_context(|| format!("append {}", path.display()))?;
Ok(())
};
append(group_path, format!("{user}:x:{id}:"))?;
append(
passwd_path,
format!("{user}:x:{id}:{id}::{home}:/usr/sbin/nologin"),
)?;
append(shadow_path, format!("{user}:!::0:99999:7:::"))?;
Ok((id, id))
}
/// **One `ExecStart=` line of a transient unit** — `(s, as, b)` on the wire.
///
/// `argv` is the FULL argument vector INCLUDING `argv[0]`, because that is what
/// `execve` takes and what systemd passes straight through for a transient unit.
/// A caller that puts only the tail in `argv` gets a process whose `argv[0]` is
/// its first real argument, so [`TransientExec::new`] fills it in rather than
/// leaving the trap open.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TransientExec {
/// The binary, absolute. Nothing resolves a relative path here: a transient
/// unit has no fragment for systemd to interpret it against.
pub program: String,
/// `argv` as `execve` wants it: `argv[0]` is the program again.
pub argv: Vec<String>,
/// `true` makes a non-zero exit NOT fail the unit. A one-off job wants
/// `false` — the exit code IS the answer being asked for.
pub ignore_failure: bool,
}
impl TransientExec {
/// `program` plus its arguments, with `argv[0]` set to `program`.
pub fn new(program: impl Into<String>, args: impl IntoIterator<Item = String>) -> Self {
let program = program.into();
let mut argv = vec![program.clone()];
argv.extend(args);
TransientExec { program, argv, ignore_failure: false }
}
}
/// **A property in a transient unit's `a(sv)` body**, in the four shapes
/// `StartTransientUnit` actually needs.
///
/// WHY an enum and not `zbus::Variant`: the caller then never touches zbus,
/// and — the part that pays — the property list a caller builds stays
/// INSPECTABLE in a test with no bus and no machine. A `Vec<(String, Value)>`
/// can only be asserted by starting a unit, which is exactly the test that
/// cannot run in CI.
///
/// Each variant also carries its own D-Bus signature
/// ([`Self::dbus_signature`]), so a refusal can say what shape it sent instead
/// of leaving an operator to guess from an `org.freedesktop.DBus.Error.InvalidArgs`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TransientValue {
/// `s` — `Description`, `Type`, `WorkingDirectory`, `StandardOutput`, …
Str(String),
/// `b` — `RemainAfterExit`, …
Bool(bool),
/// `a(sasb)` — `ExecStart`. systemd takes a LIST; a one-off job sends one.
ExecCommands(Vec<TransientExec>),
/// `a(sb)` — `EnvironmentFiles`: `(path, graceful)`. `graceful=false` makes a
/// MISSING file fail the unit, which for a job that must reach one specific
/// database is the only safe answer — a job that silently ran without its
/// environment would talk to whatever the default is.
EnvFiles(Vec<(String, bool)>),
}
impl TransientValue {
/// A `s` property, spelled so a call site reads like the unit file it replaces.
pub fn s(v: impl Into<String>) -> Self {
TransientValue::Str(v.into())
}
/// The D-Bus signature this variant serialises as. systemd rejects a property
/// whose signature it does not expect, so this is the value worth naming in
/// both a test and an error message.
pub fn dbus_signature(&self) -> &'static str {
match self {
TransientValue::Str(_) => "s",
TransientValue::Bool(_) => "b",
TransientValue::ExecCommands(_) => "a(sasb)",
TransientValue::EnvFiles(_) => "a(sb)",
}
}
/// The wire value.
///
/// Assembled by hand rather than through `From<Vec<_>>` because zbus has
/// no `Into<Variant>` for a tuple: `a(sasb)` and `a(sb)` have to be built as an
/// `Array` of `Structure`s, and the array's element signature is taken from
/// the first structure so there is no second, hand-spelled copy of it to
/// drift.
fn to_value(&self) -> anyhow::Result<zbus::Variant<'static>> {
use zbus::wire::StructureBuilder;
use zbus::{Array, Variant};
fn samla(
structs: Vec<zbus::Structure<'static>>,
what: &str,
) -> anyhow::Result<Variant<'static>> {
let Some(first) = structs.first() else {
anyhow::bail!("a `{what}` property with no elements declares nothing");
};
let mut arr = Array::new(first.signature());
for st in structs {
arr.append(Variant::Structure(st))
.map_err(|e| anyhow::anyhow!("append `{what}` element: {e}"))?;
}
Ok(Variant::Array(arr))
}
match self {
TransientValue::Str(s) => Ok(Variant::from(s.clone())),
TransientValue::Bool(b) => Ok(Variant::Bool(*b)),
TransientValue::ExecCommands(cmds) => {
let mut structs = Vec::with_capacity(cmds.len());
for c in cmds {
structs.push(
StructureBuilder::new()
.add_field(c.program.clone())
.add_field(c.argv.clone())
.add_field(c.ignore_failure)
.build()
.map_err(|e| anyhow::anyhow!("build ExecStart (sasb): {e}"))?,
);
}
samla(structs, "ExecStart")
}
TransientValue::EnvFiles(files) => {
let mut structs = Vec::with_capacity(files.len());
for (path, graceful) in files {
structs.push(
StructureBuilder::new()
.add_field(path.clone())
.add_field(*graceful)
.build()
.map_err(|e| anyhow::anyhow!("build EnvironmentFiles (sb): {e}"))?,
);
}
samla(structs, "EnvironmentFiles")
}
}
}
}
/// 100%-Rust systemd control via **D-Bus** (`org.freedesktop.systemd1`) — the
/// replacement for shelling `systemctl`. Pure-Rust `zbus` over the system bus (no
/// libdbus, no subprocess). Privileged ops (start/enable/…) need root, exactly as
/// `systemctl` did; the connection itself needs the system bus present.
#[cfg(unix)]
pub struct Systemd {
// zbus 6 removed the blocking API; the async connection is driven to
// completion with `zbus::block_on`, so every method below stays synchronous.
conn: zbus::Connection,
}
#[cfg(unix)]
impl Systemd {
pub fn connect() -> anyhow::Result<Self> {
Self::connect_scoped(Scope::System)
}
/// Connect to the systemd manager for `scope`: the SYSTEM bus for
/// [`Scope::System`] (root ops), the per-user SESSION bus for [`Scope::User`]
/// (`systemctl --user`). The session manager exposes the same
/// `org.freedesktop.systemd1` interface, so every method below is unchanged.
pub fn connect_scoped(scope: Scope) -> anyhow::Result<Self> {
let conn = if scope.is_user() {
zbus::block_on(zbus::Connection::session())
.map_err(|e| anyhow::anyhow!("connect user D-Bus (systemd --user): {e}"))?
} else {
zbus::block_on(zbus::Connection::system())
.map_err(|e| anyhow::anyhow!("connect system D-Bus (systemd): {e}"))?
};
Ok(Systemd { conn })
}
fn mgr<B>(&self, method: &str, body: &B) -> anyhow::Result<zbus::Message>
where
B: serde::Serialize + zbus::DynamicType,
{
zbus::block_on(self.conn.call_method(
Some("org.freedesktop.systemd1"),
"/org/freedesktop/systemd1",
Some("org.freedesktop.systemd1.Manager"),
method,
body,
))
.map_err(|e| anyhow::anyhow!("systemd `{method}` D-Bus call: {e}"))
}
pub fn reload(&self) -> anyhow::Result<()> {
self.mgr("Reload", &())?;
Ok(())
}
/// `EnableUnitFiles(files: as, runtime: b, force: b)`.
pub fn enable(&self, unit: &str) -> anyhow::Result<()> {
self.mgr("EnableUnitFiles", &(vec![unit], false, true))?;
Ok(())
}
/// `DisableUnitFiles(files: as, runtime: b)`.
pub fn disable(&self, unit: &str) -> anyhow::Result<()> {
self.mgr("DisableUnitFiles", &(vec![unit], false))?;
Ok(())
}
pub fn start(&self, unit: &str) -> anyhow::Result<()> {
self.mgr("StartUnit", &(unit, "replace"))?;
Ok(())
}
/// `RestartUnit(name: s, mode: s)` — stop-then-start unconditionally, so the
/// CURRENT on-disk fragment becomes the running configuration.
pub fn restart(&self, unit: &str) -> anyhow::Result<()> {
self.mgr("RestartUnit", &(unit, "replace"))?;
Ok(())
}
/// **Bring `unit` up on the unit text that is on disk RIGHT NOW.**
///
/// `StartUnit` alone cannot do this, and that is the whole defect: against a unit
/// that is already `active` it is a **no-op**. systemd has nothing to do — the
/// unit is in the requested state — so it returns success immediately without
/// re-reading anything. An installer that writes a new `ExecStart=`,
/// `daemon-reload`s and then calls `StartUnit` therefore gets a clean exit while
/// the process from the PREVIOUS build keeps serving, still on the old fragment.
///
/// Observed with three greens plus exit 0 (gunnar, 2026-08-06). Five products
/// drive their lifecycle through this layer — nornir, holger, tunnr, modgunn and
/// korp — so doing it the sanctioned way is exactly what guaranteed inheriting
/// the lie.
///
/// Restarting only when the unit is ALREADY active keeps the fresh-install path
/// a plain start, and makes the update path actually apply the update.
///
/// # `is_active` here is AMBIGUOUS, deliberately left, and guarded downstream
///
/// Swept 2026-08-25 along with every other reader of `ActiveState`, after
/// `deactivating` was found satisfying a "has it stopped?" wait (35ab55c). The
/// question this branch actually asks is *"would a plain `StartUnit` be a no-op?"*,
/// and `ActiveState == "active"` is only an approximation of it:
The file goes on; the console shows all of it.