crates/skidbladnir-systemd/src/dropin.rs
//! **DROP-IN CONFLICT — an install that is silently overridden must not report
//! success.**
//!
//! systemd resolves a unit as the fragment PLUS every `*.conf` in
//! `<unit-dir>/<unit>.service.d/`, applied in lexical order, with the drop-in
//! winning for a scalar setting like `User=`. Skidbladnir renders and writes the
//! fragment — and, until this module existed, then printed
//! `✓ installed korp-server systemd service (user korp)` while a drop-in written
//! months earlier had already decided the service runs as `alice`.
//!
//! Measured on oden 2026-08-03: `/etc/systemd/system/korp-server.service` declared
//! `User=korp`, `korp-server --install --system` reported `user korp`, and
//! `systemctl show -p User korp-server` answered `alice` — because
//! `korp-server.service.d/rootless-podman.conf` says `User=alice`. A second
//! drop-in (`cases.conf`) pinned `KORP_CASES`/`KORP_STATE` into
//! `/home/alice/.korp`, i.e. into a *different account's home* than the unit
//! claimed to run as. Both were invisible to the install.
//!
//! ## The stance this module takes
//!
//! **Report, do not refuse, and never delete.** A drop-in is systemd's own,
//! documented extension seam: an installer that refuses to manage a correctly
//! extended unit is broken, and one that takes ownership of `<unit>.service.d/`
//! silently destroys deliberate operator intent (`rootless-podman.conf` exists
//! because rootless podman genuinely needs the invoking user's runtime dir). The
//! defect was never the drop-in. The defect was the *claim*.
//!
//! So the install still happens, and then:
//!
//! 1. every contradiction is named — file, verbatim directive, what the spec
//! declared, what actually takes effect ([`DropInScan::conflict_report`]);
//! 2. the success line is derived from what systemd RESOLVED, not from what the
//! renderer wrote (see `install_systemd_scoped`), so it can never again print a
//! user the service does not run as;
//! 3. the `install_unit` surface goes RED, so a machine-readable consumer sees the
//! override too.
//!
//! And the uninstall is symmetric with what the install claims to own: Skidbladnir
//! writes the fragment and only the fragment, so the uninstall removes the fragment
//! and **keeps** every drop-in — but NAMES them in the report, because a
//! `<unit>.service.d/` left standing after the unit is gone is a landmine armed for
//! the next install. An empty (or emptied) drop-in dir is removed, since there is
//! nothing there to preserve.
//!
//! Everything below the `scan_dropins_in` seam is pure: it reads a directory and a
//! passwd-format file that the caller chooses, so the whole contract is testable
//! without root, without a bus, and without the live `/etc`.
//!
//! ## What this scan does NOT see, and what covers it
//!
//! systemd also merges drop-ins from `/run/systemd/system/<unit>.d/`,
//! `/usr/lib/systemd/system/<unit>.d/`, `.control/` dirs and `*.conf` under a
//! type-wide `service.d/`. This module reads the ONE directory that sits next to
//! the unit it just wrote — the only one an installer can meaningfully name. The
//! backstop for every other source is that `install_systemd_scoped` derives its
//! success line from
//! [`Systemd::service_user`](crate::systemd::Systemd::service_user), which is what
//! the daemon RESOLVED from all of them; a conflict this scan misses still cannot
//! produce a success line naming the wrong account.
use std::path::{Path, PathBuf};
/// What kind of contradiction a drop-in raises against the unit the spec declared.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConflictKind {
/// `User=` — the account the service actually runs as is not the declared one.
/// The one that moves `$HOME`, and therefore every `$HOME`-derived data root.
User,
/// `Group=` — likewise for the primary group.
Group,
/// `Environment=KEY=…` where the spec declares `KEY` with a DIFFERENT value.
/// The drop-in silently replaces a value the installer just rendered.
EnvOverride,
/// `Environment=KEY=…` whose value points at an absolute path inside some real
/// account's home directory that is not the declared user's. This is the shape
/// that made korp-server serve out of `/home/alice/.korp` under a unit that
/// said `User=korp`.
ForeignHome,
/// `MemoryHigh=` / `MemoryMax=` — a hand-written **resource ceiling**, the
/// setting `/etc/systemd/system/dwarves-server.service.d/10-bounded.conf` was
/// created to carry on 2026-08-20 because
/// [`crate::service::ServiceSpec`] had no memory axis at all.
///
/// Now that it does, a drop-in ceiling is a contradiction worth naming for the
/// same reason `User=` is: the resolved bound is not the declared bound, the
/// install writes only the fragment, and the uninstall therefore leaves the
/// ceiling behind to re-apply to the next install. Compared **by bytes**, not by
/// text, so `MemoryMax=24G` does not "contradict" a rendered
/// `MemoryMax=25769803776`.
MemoryCeiling,
/// `OOMPolicy=` — a hand-written **OOM REACTION**: what systemd does to the unit
/// when the kernel OOM killer (or systemd-oomd) reaps one of its processes.
///
/// The other half of [`ConflictKind::MemoryCeiling`], and unowned for the same
/// three reasons: the install writes only the fragment, the uninstall leaves the
/// drop-in behind, and every `--refresh` renders around it. Worse than a ceiling
/// in one respect — a wrong reaction is INVISIBLE until the day the service is
/// actually OOM-killed, and then it either takes the box's work down with it or
/// limps on half-reaped.
///
/// Compared by MEANING via [`crate::systemd::OomPolicy::parse`], not by text; a
/// value systemd itself would refuse is reported rather than guessed at.
OomPolicy,
/// A drop-in `StartLimitIntervalSec=` or `StartLimitBurst=` that contradicts the
/// declared [`crate::systemd::StartLimit`].
///
/// The nastiest of the four to leave unnamed, because it changes the OUTCOME OF A
/// FAILURE rather than the behaviour of a healthy service: the same spec, with one
/// number moved by an operator, flips between "parks in `failed` after five tries"
/// and "retries forever". Nothing about the running service looks different until
/// the day it starts failing, and then it does the opposite of what the spec says.
///
/// Both keys report under this one kind because they are one declaration; the
/// `directive` field carries which of the two was written.
StartLimit,
/// A drop-in `OOMScoreAdjust=` that contradicts the declared bias — a change to
/// WHICH service the kernel reaps when the box is short, i.e. a decision about the
/// unit's NEIGHBOURS made in a file none of them can see.
OomScoreAdjust,
}
impl ConflictKind {
/// A short human label for the report.
pub fn label(self) -> &'static str {
match self {
ConflictKind::User => "USER OVERRIDE",
ConflictKind::Group => "GROUP OVERRIDE",
ConflictKind::EnvOverride => "ENV OVERRIDE",
ConflictKind::MemoryCeiling => "MEMORY CEILING",
ConflictKind::OomPolicy => "OOM POLICY",
ConflictKind::StartLimit => "START LIMIT",
ConflictKind::OomScoreAdjust => "OOM SCORE",
ConflictKind::ForeignHome => "FOREIGN HOME",
}
}
}
/// ONE contradiction between a drop-in file and the spec being installed.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DropInConflict {
/// The drop-in that carries it.
pub file: PathBuf,
/// The setting, as an operator would grep for it (`User`, `Environment:KORP_CASES`).
pub key: String,
/// The verbatim directive line from the drop-in (`User=alice`).
pub directive: String,
/// What the spec asked for (empty ⇒ the spec declared nothing for this key).
pub declared: String,
/// What will actually take effect once systemd merges the drop-ins.
pub effective: String,
/// Which rule fired.
pub kind: ConflictKind,
}
impl DropInConflict {
/// One report line: file, directive, and the two values that disagree.
pub fn line(&self) -> String {
let declared = if self.declared.is_empty() {
"(not declared by the spec)".to_string()
} else {
self.declared.clone()
};
format!(
"{}: {} says `{}` — spec declared {}, EFFECTIVE is {}",
self.kind.label(),
self.file.display(),
self.directive,
declared,
self.effective,
)
}
}
/// The result of reading a unit's `<unit>.service.d/` directory.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct DropInScan {
/// The directory scanned (whether or not it exists).
pub dir: PathBuf,
/// Every `*.conf` found, in the lexical order systemd applies them.
pub files: Vec<PathBuf>,
/// Every contradiction, in file order.
pub conflicts: Vec<DropInConflict>,
/// The account the unit will actually run as once the drop-ins are merged —
/// `None` when no drop-in overrides `User=` (⇒ the spec's own user stands).
pub effective_user: Option<String>,
}
impl DropInScan {
/// Does this unit have any drop-in at all? (Independent of conflicts — a
/// drop-in dir with only additive settings is legitimate and still worth
/// naming on uninstall.)
pub fn has_dropins(&self) -> bool {
!self.files.is_empty()
}
/// Does any drop-in contradict the spec?
pub fn has_conflicts(&self) -> bool {
!self.conflicts.is_empty()
}
/// The loud, multi-line block an install prints when it is being overridden.
/// Empty string when there is nothing to say — a caller can print it
/// unconditionally.
///
/// It names the file, the directive and the winner for every conflict, so an
/// operator can act without going and reading five files.
pub fn conflict_report(&self, service: &str) -> String {
if self.conflicts.is_empty() {
return String::new();
}
let mut s = format!(
// Deliberately NOT "the unit this install just wrote": the same writer is
// now read by `dropin-check`, which writes nothing and may be run months
// after the install. A report that misstates its own context is the exact
// class of defect this module exists to remove.
"⚠ `{service}`: {} drop-in directive(s) in {} OVERRIDE the unit fragment. \
systemd applies the fragment FIRST and every drop-in after it, so these win:",
self.conflicts.len(),
self.dir.display(),
);
for c in &self.conflicts {
s.push_str("\n ! ");
s.push_str(&c.line());
}
s.push_str(
"\n The drop-ins were KEPT (they are systemd's own extension seam and this \
install does not own them). Nothing above was applied by skidbladnir — edit or \
remove the named file(s) if the spec's values are the ones you want.",
);
s
}
/// The line an uninstall prints about drop-ins it deliberately left behind.
/// Empty when the unit had none.
pub fn kept_report(&self, service: &str) -> String {
if self.files.is_empty() {
return String::new();
}
let names: Vec<String> = self
.files
.iter()
.map(|p| {
p.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default()
})
.collect();
format!(
"drop-in dir {} KEPT with {} file(s) [{}] — skidbladnir installs the unit \
fragment only and never wrote these, so `{service}`'s uninstall does not delete \
them. ⚠ They will apply again to the NEXT install of this unit.",
self.dir.display(),
names.len(),
names.join(", "),
)
}
}
/// The declaration an install is making, as the drop-in scanner needs to see it.
/// Kept primitive (not a `SystemdServiceSpec`) so the whole rule set is exercisable
/// from a test with three string literals.
#[derive(Debug, Clone, Default)]
pub struct Declared {
/// `User=` the spec renders.
pub user: String,
/// `Group=` the spec renders.
pub group: String,
/// Every `Environment=KEY=VALUE` the spec renders inline. Secrets are NOT here
/// (they never reach the world-readable unit) and must not be — a conflict
/// report is printed to a terminal.
pub env: Vec<(String, String)>,
/// `MemoryHigh=` the spec renders — the SOFT resource ceiling, **verbatim in the
/// spelling the fragment carries** (`"16G"`). Empty ⇒ the spec declares no soft
/// ceiling. Compared against a drop-in's value by BYTES
/// ([`crate::systemd::MemoryBound::parse_bytes`]), so two spellings of one size
/// are not a contradiction.
#[doc(alias = "MemoryHigh")]
pub memory_high: String,
/// `MemoryMax=` the spec renders — the HARD resource ceiling. Empty ⇒ none
/// declared. Same byte-wise comparison as [`Declared::memory_high`].
#[doc(alias = "MemoryMax")]
pub memory_max: String,
/// `OOMPolicy=` the spec renders — THE REACTION to being OOM-killed, verbatim
/// (`"continue"`). Empty ⇒ the spec declares none, and the unit then inherits the
/// manager's `DefaultOOMPolicy=`, which makes any drop-in reaction an override
/// worth naming. Compared by MEANING
/// ([`crate::systemd::OomPolicy::parse`]), not by text.
#[doc(alias = "OOMPolicy")]
pub oom_policy: String,
/// `StartLimitIntervalSec=` the spec renders, verbatim (`"12"`). Empty ⇒ the spec
/// declares no rate limit, and the unit inherits the manager's
/// `DefaultStartLimitIntervalSec=` — which makes any drop-in one an override worth
/// naming, exactly as an undeclared ceiling makes any drop-in ceiling one.
#[doc(alias = "StartLimitIntervalSec")]
pub start_limit_interval: String,
/// `StartLimitBurst=` the spec renders, verbatim (`"5"`). Empty ⇒ undeclared; see
/// [`start_limit_interval`](Declared::start_limit_interval).
#[doc(alias = "StartLimitBurst")]
pub start_limit_burst: String,
/// `OOMScoreAdjust=` the spec renders, verbatim (`"500"`). Empty ⇒ the spec
/// declares none and the unit inherits the manager's level (measured `0`).
#[doc(alias = "OOMScoreAdjust")]
pub oom_score_adjust: String,
}
/// The directory systemd reads drop-ins from for `unit_file_name` under `unit_dir`
/// — `<unit_dir>/<unit>.service.d`. The ONE place that name is built.
pub fn dropin_dir(unit_dir: &Path, unit_file_name: &str) -> PathBuf {
unit_dir.join(format!("{unit_file_name}.d"))
}
/// **AUDIT SEAM — read a [`Declared`] back out of an INSTALLED unit fragment.**
///
/// [`scan_dropins_in`] answers "is this install being overridden?" and until now the
/// only caller who could ask was the install itself, which holds the
/// `SystemdServiceSpec` it just rendered. That makes the check unavailable exactly
/// when it is cheapest and safest to run — on a running box, months later, changing
/// nothing.
///
/// A fragment on disk carries the same three facts a spec declares (`User=`,
/// `Group=`, the inline `Environment=` lines), so this reconstructs the declaration
/// from the file with the SAME parser the drop-ins go through
/// ([`parse_service_directives`] + [`split_env_assignments`]) — never a second,
/// drifting notion of what a unit says.
///
/// Two deliberate details:
///
/// * `Group=` follows systemd's own default (`Group=` absent ⇒ the primary group of
/// `User=`), so a unit that names only a user does not produce a phantom
/// GROUP OVERRIDE against the empty string.
/// * A fragment with NO `User=` yields an empty [`Declared::user`], which is exactly
/// what a `Scope::User` spec renders — the foreign-home rule then correctly stands
/// down (see [`scan_dropins_in`]).
///
/// A `.service` file whose LAST `User=` is the effective one is what is recorded:
/// within a single file systemd also takes the last assignment of a scalar.
pub fn declared_from_unit_file(unit_path: &Path) -> std::io::Result<Declared> {
let text = std::fs::read_to_string(unit_path)?;
Ok(declared_from_unit_text(&text))
}
/// The pure half of [`declared_from_unit_file`] — testable with a string literal,
/// no disk.
pub fn declared_from_unit_text(text: &str) -> Declared {
let mut d = Declared::default();
let mut group_declared = false;
for (key, value) in parse_service_directives(text)
.into_iter()
.chain(parse_directives_in(text, "[Unit]"))
{
match key.as_str() {
// Last assignment wins for a scalar, inside one file as well as across
// drop-ins.
"User" => d.user = value,
// The RESOURCE CEILING as the fragment carries it. Read back so an
// identical drop-in ceiling is not reported as a contradiction, and a
// DIFFERENT one is. Last assignment of a scalar wins, here as in systemd.
"MemoryHigh" => d.memory_high = value,
"MemoryMax" => d.memory_max = value,
// …and THE REACTION the fragment carries, read back for the same reason:
// so a drop-in that AGREES with the declaration is not reported, and one
// that changes it is.
"OOMPolicy" => d.oom_policy = value,
"OOMScoreAdjust" => d.oom_score_adjust = value,
"StartLimitIntervalSec" => d.start_limit_interval = value,
"StartLimitBurst" => d.start_limit_burst = value,
"Group" => {
d.group = value;
group_declared = true;
}
"Environment" => {
for (k, v) in split_env_assignments(&value) {
// A repeated key in one fragment: the last assignment is the one
// that takes effect, so replace rather than push a duplicate that
// would make the conflict rules compare against a stale value.
match d.env.iter_mut().find(|(ek, _)| *ek == k) {
Some(slot) => slot.1 = v,
None => d.env.push((k, v)),
}
}
}
_ => {}
}
}
// systemd: no `Group=` ⇒ the user's primary group. Mirror that, or every unit
// that names only `User=` reports a GROUP OVERRIDE against "".
if !group_declared {
d.group = d.user.clone();
}
d
}
/// Parse a systemd unit/drop-in body into its `[Service]` directives, in file order.
///
/// Handles what a real drop-in uses: section headers, `#`/`;` comments, blank
/// lines, and backslash line-continuation. Returns `(key, value)` with surrounding
/// whitespace trimmed.
///
/// Kept as-is for every caller that wants the service body; [`parse_directives_in`]
/// is the general form. The old comment here said directives outside `[Service]` are
/// ignored because "a `[Unit]` `After=` cannot contradict `User=`" — true of the
/// rules that existed then, and FALSE as of the start-limit axis: `StartLimitBurst=`
/// and `StartLimitIntervalSec=` are `[Unit]` keys, so a `[Service]`-only parser made
/// the whole rate limit unauditable.
pub fn parse_service_directives(text: &str) -> Vec<(String, String)> {
parse_directives_in(text, "[Service]")
}
/// **Parse one named section's directives** — the general form of
/// [`parse_service_directives`].
///
/// It exists because the audit was `[Service]`-only while systemd spreads ONE
/// service's behaviour across sections: `RestartSec=` is a `[Service]` key and the
/// `StartLimitIntervalSec=`/`StartLimitBurst=` it runs into are `[Unit]` keys. A
/// scanner that could not read `[Unit]` could not see a drop-in changing the outcome
/// of every failure — it would report the drop-in file as present and find nothing
/// wrong in it.
///
/// `section` is matched case-insensitively, whole, including the brackets
/// (`"[Unit]"`, `"[Service]"`).
pub fn parse_directives_in(text: &str, section: &str) -> Vec<(String, String)> {
let mut out = Vec::new();
let mut in_service = false;
let mut pending = String::new();
for raw in text.lines() {
let line = raw.trim();
// A continuation in progress swallows the line whole (including a line that
// looks like a comment — systemd continues first).
if !pending.is_empty() {
let (body, more) = strip_continuation(line);
pending.push(' ');
pending.push_str(body);
if !more {
let done = std::mem::take(&mut pending);
push_directive(&mut out, &done, in_service);
}
continue;
}
if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
continue;
}
if line.starts_with('[') {
in_service = line.eq_ignore_ascii_case(section);
continue;
}
let (body, more) = strip_continuation(line);
if more {
pending.push_str(body);
} else {
push_directive(&mut out, body, in_service);
}
}
if !pending.is_empty() {
push_directive(&mut out, &pending, in_service);
}
out
}
/// `(line without a trailing `\`, does it continue?)`.
fn strip_continuation(line: &str) -> (&str, bool) {
match line.strip_suffix('\\') {
Some(body) => (body.trim_end(), true),
None => (line, false),
}
}
fn push_directive(out: &mut Vec<(String, String)>, line: &str, in_service: bool) {
if !in_service {
return;
}
if let Some((k, v)) = line.split_once('=') {
out.push((k.trim().to_string(), v.trim().to_string()));
}
}
/// Split an `Environment=` value into the `KEY=VALUE` pairs it carries.
///
/// One `Environment=` line may hold several space-separated assignments
/// (`Environment=A=1 B=2`) — the live korp drop-ins use one per line, but systemd
/// permits both and a scanner that missed the packed form would be a hole. Quoted
/// values keep their spaces.
pub fn split_env_assignments(value: &str) -> Vec<(String, String)> {
let mut out = Vec::new();
let mut cur = String::new();
let mut quote: Option<char> = None;
for ch in value.chars() {
match quote {
Some(q) => {
if ch == q {
quote = None;
} else {
cur.push(ch);
}
}
None => match ch {
'"' | '\'' => quote = Some(ch),
c if c.is_whitespace() => {
if !cur.is_empty() {
out.push(std::mem::take(&mut cur));
}
}
c => cur.push(c),
},
}
}
if !cur.is_empty() {
out.push(cur);
}
out.into_iter()
.filter_map(|tok| {
tok.split_once('=')
.map(|(k, v)| (k.to_string(), v.to_string()))
})
.collect()
}
/// Every absolute path that appears inside an env VALUE (`/a/b`, and the path part
/// of a `unix:///a/b` URL). Used to spot a value that pins state into a home.
fn absolute_paths_in(value: &str) -> Vec<&str> {
let mut out = Vec::new();
let mut rest = value;
while let Some(i) = rest.find('/') {
let tail = &rest[i..];
let end = tail
.find(|c: char| c.is_whitespace() || c == ':' || c == ',')
.unwrap_or(tail.len());
let cand = &tail[..end];
if cand.len() > 1 {
out.push(cand);
}
rest = &tail[end..];
if rest.is_empty() {
break;
}
// step past the delimiter so `find('/')` cannot loop on the same index
rest = &rest[1..];
}
out
}
/// **Do a declared and a drop-in resource ceiling mean the SAME size?**
///
/// By bytes, not by text: systemd takes `16G`, `16384M` and `17179869184` as one
/// number, and a scan that string-compared them would report a contradiction where
/// there is none — which is how a conflict report becomes noise an operator learns to
/// skip. An unparseable value on either side (`infinity`, a typo) is treated as
/// "different unless textually identical", because guessing what an unparseable
/// ceiling means is the one thing worse than naming it.
fn same_ceiling(declared: &str, dropin: &str) -> bool {
if declared.trim() == dropin.trim() {
return true;
}
match (
crate::systemd::MemoryBound::parse_bytes(declared),
crate::systemd::MemoryBound::parse_bytes(dropin),
) {
(Some(a), Some(b)) => a == b,
_ => false,
}
}
/// **Do a declared and a drop-in OOM reaction mean the SAME thing?**
///
/// The same rule [`same_ceiling`] applies to sizes, one axis over. Text equality
/// first (so a spec and a drop-in that both say `stop` agree without a parse), then
/// by MEANING through [`crate::systemd::OomPolicy::parse`]. A value on either side
/// that systemd itself would refuse — `Stop`, `continu` — is treated as DIFFERENT
/// unless textually identical: a directive that would stop the unit from loading is
/// worth naming, and guessing what it meant is the one thing worse than naming it.
/// **Do a declared and a drop-in rate-limit number mean the SAME thing?**
///
/// By VALUE, not by text — `12`, `12s` and `12sec` are one number to systemd, and a
/// bare integer is seconds (`man systemd.time`). Only the plain-seconds spellings are
/// understood; anything richer (`1min`, `infinity`) falls back to a textual compare,
/// the same "guessing is worse than naming it" rule
/// [`crate::systemd::MemoryBound::parse_bytes`] applies to sizes.
fn same_start_limit(declared: &str, dropin: &str) -> bool {
if declared.trim() == dropin.trim() {
return true;
}
let secs = |t: &str| -> Option<u64> {
let t = t.trim();
let n = t
.strip_suffix("sec")
.or_else(|| t.strip_suffix('s'))
.unwrap_or(t);
n.trim().parse::<u64>().ok()
};
match (secs(declared), secs(dropin)) {
(Some(a), Some(b)) => a == b,
_ => false,
}
}
fn same_oom_policy(declared: &str, dropin: &str) -> bool {
if declared.trim() == dropin.trim() {
return true;
}
match (
crate::systemd::OomPolicy::parse(declared),
crate::systemd::OomPolicy::parse(dropin),
) {
(Some(a), Some(b)) => a == b,
_ => false,
}
}
/// **SCAN — read `dir` and report every drop-in directive that contradicts
/// `declared`.** Pure: it touches only `dir` and `passwd_path`, so a test points it
/// at a temp dir and a fixture passwd and exercises the whole rule set with no
/// root, no bus and no live `/etc`.
///
/// A missing `dir` is not an error — it is the normal case, and yields an empty
/// scan.
///
/// Rules, in the order systemd itself resolves them (last writer wins for a
/// scalar):
///
/// * `User=`/`Group=` that differ from the declaration;
/// * `MemoryHigh=`/`MemoryMax=` that differ from the declared ceiling, compared by
/// BYTES via [`same_ceiling`] so two spellings of one size are not a contradiction;
/// * `OOMPolicy=` that differs from the declared reaction, compared by MEANING via
/// [`same_oom_policy`];
/// * `StartLimitIntervalSec=`/`StartLimitBurst=` that differ from the declared rate
/// limit, compared as NUMBERS via [`same_start_limit`] (`12`, `12s` and `12sec` are
/// one value). These are `[Unit]` keys, not `[Service]` keys — see below;
/// * `OOMScoreAdjust=` that differs from the declared bias, compared as a NUMBER so
/// `+500` and `500` are one value;
/// * an `Environment=` key the spec also declares, with a different value;
/// * an `Environment=` value holding an absolute path under a real account's home
/// that is not the declared user's.
///
/// An EMPTY [`Declared::user`] means "this unit runs as whoever invoked it" (a
/// `Scope::User` install, which renders no `User=` at all). The foreign-home rule
/// is then off — a per-user service keeping state in its own operator's home is the
/// normal case — while a `User=` drop-in is still reported, because a `--user` unit
/// that tries to switch account is broken either way.
///
/// An empty declared CEILING, REACTION or RATE LIMIT is not the same stand-down:
/// those three mean "the spec declares none", and a drop-in that supplies one has
/// decided something the install did not — which is the `10-bounded.conf` case
/// exactly.
///
/// **Both `[Service]` AND `[Unit]` are read.** One service's behaviour is spread
/// across sections by systemd: `RestartSec=` is a `[Service]` key while the
/// `StartLimitIntervalSec=`/`StartLimitBurst=` it runs into are `[Unit]` keys. Until
/// the start-limit axis this scan parsed `[Service]` only, so a drop-in rewriting the
/// outcome of every failure was reported as a drop-in with nothing wrong in it.
pub fn scan_dropins_in(dir: &Path, declared: &Declared, passwd_path: &Path) -> DropInScan {
let mut scan = DropInScan {
dir: dir.to_path_buf(),
..Default::default()
};
let mut files: Vec<PathBuf> = match std::fs::read_dir(dir) {
Ok(rd) => rd
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|x| x == "conf") && p.is_file())
.collect(),
Err(_) => return scan, // no dir ⇒ no drop-ins; the normal, quiet case
};
// systemd applies drop-ins in lexical order of file name; so do we, and the LAST
// value of a scalar is the one that takes effect.
files.sort();
scan.files = files.clone();
// The homes of real accounts, from the SAME passwd parser the chown/delete guard
// reads (`systemd::passwd_homes_of`) — never a second, drifting notion of "home".
let homes = crate::systemd::passwd_homes_of(passwd_path);
let declared_home: Option<PathBuf> = homes
.iter()
.find(|(n, _, _)| *n == declared.user)
.map(|(_, _, h)| h.clone());
for file in &files {
let Ok(text) = std::fs::read_to_string(file) else {
continue;
};
// BOTH sections. systemd spreads one service's behaviour across them — the
// restart cadence is a `[Service]` key and the rate limit it runs into is a
// `[Unit]` key — so a `[Service]`-only scan reported the drop-in as present
// and found nothing wrong inside it. Chained rather than merged so `[Service]`
// keys keep their existing report order and no existing conflict moves.
let directives = parse_service_directives(&text)
.into_iter()
.chain(parse_directives_in(&text, "[Unit]"));
for (key, value) in directives {
match key.as_str() {
"User" if value != declared.user => {
scan.effective_user = Some(value.clone());
scan.conflicts.push(DropInConflict {
file: file.clone(),
key: "User".into(),
directive: format!("User={value}"),
declared: declared.user.clone(),
effective: value.clone(),
kind: ConflictKind::User,
});
}
"Group" if value != declared.group => {
scan.conflicts.push(DropInConflict {
file: file.clone(),
key: "Group".into(),
directive: format!("Group={value}"),
declared: declared.group.clone(),
effective: value.clone(),
kind: ConflictKind::Group,
});
}
// The RESOURCE CEILING. A hand-written `MemoryHigh=`/`MemoryMax=` is
// exactly the artefact the 2026-08-20 incident produced, and once the
// spec can DECLARE a ceiling, a drop-in that sets a different one is a
// contradiction of the same class as `User=`: the install writes only
// the fragment, so the uninstall leaves the drop-in behind to re-apply
// to the next install.
//
// Compared by BYTES, never by text — `MemoryMax=24G` and
// `MemoryMax=25769803776` are the same ceiling, and reporting those two
// as a conflict would train an operator to ignore the report.
"MemoryHigh" | "MemoryMax" => {
let declared_val = if key == "MemoryHigh" {
&declared.memory_high
} else {
&declared.memory_max
};
if !same_ceiling(declared_val, &value) {
scan.conflicts.push(DropInConflict {
file: file.clone(),
key: key.clone(),
directive: format!("{key}={value}"),
declared: declared_val.clone(),
effective: value.clone(),
kind: ConflictKind::MemoryCeiling,
});
}
}
// THE REACTION. A hand-written `OOMPolicy=` is the ceiling's other
// half and is unowned in exactly the same way — and it is the harder
// one to notice, because a wrong ceiling shows up as a service that
// gets killed too early, while a wrong reaction shows up only on the
// day it is actually killed.
//
// An EMPTY declaration is not "no opinion, so anything goes": the unit
// inherits the manager's `DefaultOOMPolicy=` (measured `stop`), and a
// drop-in that replaces that with `continue` has changed what happens
// to the service under memory pressure without the install knowing.
"OOMPolicy" => {
if !same_oom_policy(&declared.oom_policy, &value) {
scan.conflicts.push(DropInConflict {
file: file.clone(),
key: "OOMPolicy".into(),
directive: format!("OOMPolicy={value}"),
declared: declared.oom_policy.clone(),
effective: value.clone(),
kind: ConflictKind::OomPolicy,
});
}
}
// THE RATE LIMIT. Compared as NUMBERS, not text, for the same reason
// the ceiling is compared as bytes: systemd accepts `12`, `12s` and
// `12sec` as one value, and a scan that string-compared them would
// report a contradiction where there is none.
//
// Reported even when the spec declares NOTHING, and that is the case
// that matters most here — an undeclared spec inherits the box's 10s/5
// window, so a drop-in supplying either number has decided between
// "park" and "retry forever" on behalf of an install that never had an
// opinion. That is precisely how dwarves-server ended up thrashing.
"StartLimitIntervalSec" | "StartLimitBurst" => {
let declared_val = if key == "StartLimitIntervalSec" {
&declared.start_limit_interval
} else {
&declared.start_limit_burst
};
if !same_start_limit(declared_val, &value) {
scan.conflicts.push(DropInConflict {
file: file.clone(),
key: key.clone(),
directive: format!("{key}={value}"),
declared: declared_val.clone(),
effective: value.clone(),
kind: ConflictKind::StartLimit,
});
}
}
// WHICH unit the kernel picks. Compared as a NUMBER so `+500` and
// `500` are one value. Reported against an empty declaration too: the
// unit then sits at the manager's level with every other service, and
// a drop-in moving it has re-ranked this service against its
// neighbours — which is what nobody could see on 2026-08-20.
"OOMScoreAdjust" => {
let same = declared.oom_score_adjust.trim() == value.trim()
|| matches!(
(
declared.oom_score_adjust.trim().parse::<i32>(),
value.trim().parse::<i32>()
),
(Ok(a), Ok(b)) if a == b
);
if !same {
scan.conflicts.push(DropInConflict {
file: file.clone(),
key: "OOMScoreAdjust".into(),
directive: format!("OOMScoreAdjust={value}"),
declared: declared.oom_score_adjust.clone(),
effective: value.clone(),
kind: ConflictKind::OomScoreAdjust,
});
}
}
"Environment" => {
for (k, v) in split_env_assignments(&value) {
// (a) the spec declares this key with a different value
if let Some((_, spec_v)) = declared.env.iter().find(|(dk, _)| *dk == k) {
if *spec_v != v {
scan.conflicts.push(DropInConflict {
file: file.clone(),
key: format!("Environment:{k}"),
directive: format!("Environment={k}={v}"),
declared: spec_v.clone(),
effective: v.clone(),
kind: ConflictKind::EnvOverride,
});
continue;
}
}
// (b) the value pins a path into somebody else's home.
// Skipped when no account is declared (a `Scope::User` unit
// runs as WHOEVER invoked it, so a path in that person's
// home is the normal case, not a contradiction).
if declared.user.is_empty() {
continue;
}
if let Some(owner) =
foreign_home_owner(&v, &homes, &declared.user, declared_home.as_deref())
{
scan.conflicts.push(DropInConflict {
file: file.clone(),
key: format!("Environment:{k}"),
directive: format!("Environment={k}={v}"),
declared: declared_home
.as_ref()
.map(|h| format!("a path under {}", h.display()))
.unwrap_or_else(|| {
format!("a path owned by `{}`", declared.user)
}),
effective: format!("{v} (inside `{owner}`'s home)"),
kind: ConflictKind::ForeignHome,
});
}
}
}
_ => {}
}
}
}
scan
}
/// One installed unit, read off disk: what its fragment DECLARES and what its
/// `<unit>.service.d/` does to that. The read-only diagnostic form of the check
/// [`install_systemd_scoped`](crate::systemd::install_systemd_scoped) runs inline.
#[derive(Debug, Clone)]
pub struct UnitAudit {
/// `<unit>.service` — the name systemd knows it by.
pub unit_file_name: String,
/// The fragment that was read.
pub unit_path: PathBuf,
/// What the fragment declares (`User=`, `Group=`, inline `Environment=`).
pub declared: Declared,
/// What its drop-in dir does to that.
pub scan: DropInScan,
}
/// **Audit an INSTALLED unit — read its fragment, then scan its drop-in dir against
/// it.** Read-only: it opens two paths and writes nothing.
///
/// `Ok(None)` when `unit_dir/<unit_file_name>` does not exist — a unit this scope
/// does not have is not an error, it is simply not ours to audit.
pub fn audit_unit_in(
unit_dir: &Path,
unit_file_name: &str,
passwd_path: &Path,
) -> std::io::Result<Option<UnitAudit>> {
let unit_path = unit_dir.join(unit_file_name);
if !unit_path.is_file() {
return Ok(None);
}
let declared = declared_from_unit_file(&unit_path)?;
let scan = scan_dropins_in(
&dropin_dir(unit_dir, unit_file_name),
&declared,
passwd_path,
);
Ok(Some(UnitAudit {
unit_file_name: unit_file_name.to_string(),
unit_path,
declared,
scan,
}))
}
/// Every `*.service` fragment in `unit_dir` that is a REGULAR FILE, sorted.
///
/// Symlinks are skipped deliberately: `/etc/systemd/system` is full of enablement
/// links into `/usr/lib/systemd/system` (and `→ /dev/null` masks), and neither is a
/// unit an installer wrote there.
pub fn unit_files_in(unit_dir: &Path) -> Vec<String> {
let Ok(rd) = std::fs::read_dir(unit_dir) else {
return Vec::new();
};
let mut names: Vec<String> = rd
.filter_map(|e| e.ok())
.filter(|e| {
// `file_type()` does NOT follow the link, which is the point.
e.file_type().map(|t| t.is_file()).unwrap_or(false)
})
.filter_map(|e| e.file_name().into_string().ok())
.filter(|n| n.ends_with(".service"))
.collect();
names.sort();
names
}
/// The account whose home contains an absolute path in `value`, when that account
/// is NOT `declared_user`. `None` when the value holds no such path.
///
/// Deliberately conservative: only a path under a home listed in passwd counts, and
/// the declared user's own home never does. `/var/lib/foo` is not a conflict.
fn foreign_home_owner(
value: &str,
homes: &[(String, u32, PathBuf)],
declared_user: &str,
declared_home: Option<&Path>,
) -> Option<String> {
for cand in absolute_paths_in(value) {
let p = Path::new(cand);
if declared_home.is_some_and(|h| p.starts_with(h)) {
continue;
}
for (name, _, home) in homes {
if name == declared_user {
continue;
}
// A home of `/` or `/nonexistent` is not a real home and would match
// everything / nothing useful.
if home.parent().is_none() || !home.is_absolute() {
continue;
}
if p.starts_with(home) && p != home.as_path() {
return Some(name.clone());
}
}
}
None
}
// ─────────────────────────────────── tests ───────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
/// The five drop-ins that were live in `/etc/systemd/system/korp-server.service.d/`
/// on oden, 2026-08-03, verbatim. This is the RED fixture: the whole point of the
/// module is that these files must not be able to sit silently under an install
/// that prints `user korp`.
const ROOTLESS_PODMAN: &str = "\
# Run korp-server as `alice` so its infra-autostart (draupnir) can drive the
# ROOTLESS podman engine that owns the korp zone containers.
[Service]
User=alice
Group=alice
Environment=XDG_RUNTIME_DIR=/run/user/1000
Environment=DOCKER_HOST=unix:///run/user/1000/podman/podman.sock
";
const CASES: &str = "\
[Service]
Environment=KORP_CASES=/home/alice/.korp/korp_cases.redb
Environment=KORP_STATE=/home/alice/.korp/korp_server_state.json
";
const ADDR: &str = "[Service]\nEnvironment=KORP_SERVER_ADDR=0.0.0.0:50051\n";
const MINIO: &str = "\
[Service]
Environment=KORP_MINIO_ENDPOINT=http://localhost:9000
Environment=KORP_MINIO_ACCESS_KEY=minioadmin
";
const REPACK: &str = "[Service]\nEnvironment=KORP_MAP_REPACK=sweden\n";
const PASSWD: &str = "\
root:x:0:0:root:/root:/bin/bash
alice:x:1000:1000:Alice:/home/alice:/bin/bash
korp:x:111:111::/home/korp:/usr/sbin/nologin
nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin
";
fn korp_declared() -> Declared {
Declared {
user: "korp".into(),
group: "korp".into(),
// No ceiling declared — the state every unit was in before the memory axis
// existed, and the state the 2026-08-20 drop-in was written against.
memory_high: String::new(),
memory_max: String::new(),
// No reaction declared either — the unit inherits the box's
// `DefaultOOMPolicy=`, which is the state every unit was in before the
// axis existed.
oom_policy: String::new(),
// No rate limit declared — the state EVERY unit shipped by this crate is
// in until a spec opts in, and the state dwarves-server was in for all
// 217830 of its restarts.
start_limit_interval: String::new(),
start_limit_burst: String::new(),
oom_score_adjust: String::new(),
env: vec![("KORP_SERVER_ADDR".into(), "127.0.0.1:50051".into())],
}
}
struct Bed {
_tmp: tempfile::TempDir,
dir: PathBuf,
passwd: PathBuf,
}
fn bed(files: &[(&str, &str)]) -> Bed {
let tmp = tempfile::tempdir().expect("tempdir");
let dir = tmp.path().join("korp-server.service.d");
std::fs::create_dir_all(&dir).unwrap();
for (name, body) in files {
std::fs::write(dir.join(name), body).unwrap();
}
let passwd = tmp.path().join("passwd");
std::fs::write(&passwd, PASSWD).unwrap();
Bed {
_tmp: tmp,
dir,
passwd,
}
}
fn oden_bed() -> Bed {
bed(&[
("20-repack.conf", REPACK),
("addr.conf", ADDR),
("cases.conf", CASES),
("minio.conf", MINIO),
("rootless-podman.conf", ROOTLESS_PODMAN),
])
}
/// ★ RED — the live oden drop-in set, scanned against the spec korp installs.
/// Every directive that decided the 2026-08-03 incident must be named.
#[test]
fn oden_dropins_contradict_the_korp_spec() {
let b = oden_bed();
let scan = scan_dropins_in(&b.dir, &korp_declared(), &b.passwd);
assert!(scan.has_dropins(), "five .conf files must be seen");
assert_eq!(scan.files.len(), 5);
assert!(scan.has_conflicts(), "the install MUST NOT look clean here");
// The one that moved $HOME and therefore the whole data root.
let user_c = scan
.conflicts
.iter()
.find(|c| c.kind == ConflictKind::User)
.expect("the User= override must be caught");
assert!(
user_c.file.ends_with("rootless-podman.conf"),
"named the wrong file"
);
assert_eq!(user_c.directive, "User=alice");
assert_eq!(user_c.declared, "korp");
assert_eq!(user_c.effective, "alice");
assert_eq!(scan.effective_user.as_deref(), Some("alice"));
assert!(
scan.conflicts
.iter()
.any(|c| c.kind == ConflictKind::Group && c.directive == "Group=alice"),
"the Group= override must be caught"
);
// The absolute paths into ANOTHER account's home — the actual data-root bug.
let foreign: Vec<&str> = scan
.conflicts
.iter()
.filter(|c| c.kind == ConflictKind::ForeignHome)
.map(|c| c.key.as_str())
.collect();
assert!(
foreign.contains(&"Environment:KORP_CASES")
&& foreign.contains(&"Environment:KORP_STATE"),
"both /home/alice/.korp pins must be caught, got {foreign:?}"
);
assert!(
scan.conflicts
.iter()
.any(|c| c.kind == ConflictKind::ForeignHome && c.file.ends_with("cases.conf")),
"the foreign-home conflict must name cases.conf"
);
// The listen address the spec rendered, silently replaced.
let addr = scan
.conflicts
.iter()
.find(|c| c.key == "Environment:KORP_SERVER_ADDR")
.expect("the addr override must be caught");
assert_eq!(addr.kind, ConflictKind::EnvOverride);
assert_eq!(addr.declared, "127.0.0.1:50051");
assert_eq!(addr.effective, "0.0.0.0:50051");
// The report an operator reads must carry file + directive + winner.
let rep = scan.conflict_report("korp-server");
assert!(
rep.contains("rootless-podman.conf"),
"report hides the file: {rep}"
);
assert!(
rep.contains("User=alice"),
"report hides the directive: {rep}"
);
assert!(rep.contains("cases.conf"), "report hides cases.conf: {rep}");
assert!(
rep.contains("KEPT"),
"report must say the drop-ins were kept, not deleted: {rep}"
);
}
/// ★ GREEN — the SAME scanner, the SAME spec, with the drop-in dir gone. The
/// guard has to be able to say "clean", or it is just noise.
#[test]
fn no_dropin_dir_is_clean_and_says_nothing() {
let tmp = tempfile::tempdir().unwrap();
let passwd = tmp.path().join("passwd");
std::fs::write(&passwd, PASSWD).unwrap();
let scan = scan_dropins_in(
&tmp.path().join("absent.service.d"),
&korp_declared(),
&passwd,
);
assert!(!scan.has_dropins());
assert!(!scan.has_conflicts());
assert_eq!(
scan.effective_user, None,
"no override ⇒ the spec's user stands"
);
assert_eq!(scan.conflict_report("korp-server"), "");
assert_eq!(scan.kept_report("korp-server"), "");
}
/// ★ GREEN — a drop-in dir that exists but only ADDS is not a conflict. Without
/// this the check would fire on every extended unit and be turned off.
#[test]
fn additive_dropins_are_not_conflicts_but_are_still_named() {
let b = bed(&[
("20-repack.conf", REPACK),
("minio.conf", MINIO),
(
"extra.conf",
"[Service]\nEnvironment=KORP_EXTRA=/var/lib/korp/extra\n",
),
]);
let scan = scan_dropins_in(&b.dir, &korp_declared(), &b.passwd);
assert!(scan.has_dropins());
assert!(
!scan.has_conflicts(),
"additive-only drop-ins must be quiet, got {:?}",
scan.conflicts
);
// …but the UNINSTALL still names them, because they re-arm the next install.
let kept = scan.kept_report("korp-server");
assert!(
kept.contains("extra.conf"),
"uninstall must name what it kept: {kept}"
);
assert!(
kept.contains("NEXT install"),
"uninstall must say why it matters: {kept}"
);
}
/// A drop-in that re-states the spec's OWN values contradicts nothing — the
/// identity-value trap, checked deliberately.
#[test]
fn a_dropin_restating_the_spec_is_not_a_conflict() {
let b = bed(&[(
"same.conf",
"[Service]\nUser=korp\nGroup=korp\nEnvironment=KORP_SERVER_ADDR=127.0.0.1:50051\n",
)]);
let scan = scan_dropins_in(&b.dir, &korp_declared(), &b.passwd);
assert!(!scan.has_conflicts(), "got {:?}", scan.conflicts);
assert_eq!(scan.effective_user, None);
}
/// The declared user's OWN home is not a foreign home — a system service whose
/// env legitimately points at `/home/korp/...` must not be flagged.
#[test]
fn the_declared_users_own_home_is_not_foreign() {
let b = bed(&[(
"own.conf",
"[Service]\nEnvironment=KORP_CASES=/home/korp/.korp/korp_cases.redb\n",
)]);
let scan = scan_dropins_in(&b.dir, &korp_declared(), &b.passwd);
assert!(!scan.has_conflicts(), "got {:?}", scan.conflicts);
}
/// Lexical order decides: the LAST `User=` is the effective one, exactly as
/// systemd merges them.
#[test]
fn the_last_user_override_wins_like_systemd() {
let b = bed(&[
("10-a.conf", "[Service]\nUser=nobody\n"),
("90-z.conf", "[Service]\nUser=alice\n"),
]);
let scan = scan_dropins_in(&b.dir, &korp_declared(), &b.passwd);
assert_eq!(scan.effective_user.as_deref(), Some("alice"));
assert_eq!(
scan.conflicts.len(),
2,
"both are reported, the last one wins"
);
}
/// Parsing is not a `contains("User=")` — comments, other sections and
/// continuations must all be handled, or the check is fooled by its own input.
#[test]
fn parser_ignores_comments_and_other_sections_and_joins_continuations() {
let text = "\
[Unit]
Description=not a service directive
# User=commented-out
[Service]
; also a comment
User=alice
ExecStart=/usr/bin/thing \\
--flag one \\
--flag two
Environment=A=1 B=2
";
let d = parse_service_directives(text);
assert!(
!d.iter().any(|(k, _)| k == "Description"),
"a [Unit] directive leaked into [Service]"
);
assert!(!d.iter().any(|(_, v)| v.contains("commented-out")));
assert_eq!(
d.iter().find(|(k, _)| k == "User").map(|(_, v)| v.as_str()),
Some("alice")
);
let exec = d
.iter()
.find(|(k, _)| k == "ExecStart")
.expect("ExecStart")
.1
.clone();
assert!(
exec.contains("--flag one") && exec.contains("--flag two"),
"continuation not joined: {exec}"
);
let env = d
.iter()
.find(|(k, _)| k == "Environment")
.unwrap()
.1
.clone();
assert_eq!(
split_env_assignments(&env),
vec![
("A".to_string(), "1".to_string()),
("B".to_string(), "2".to_string())
],
"a packed Environment= line must split into both assignments"
);
}
/// A packed `Environment=` line hides a foreign-home pin behind a harmless one —
/// the scanner must still find it.
#[test]
fn a_packed_environment_line_cannot_hide_a_foreign_home() {
let b = bed(&[(
"packed.conf",
"[Service]\nEnvironment=SAFE=/var/lib/korp KORP_CASES=/home/alice/.korp/c.redb\n",
)]);
let scan = scan_dropins_in(&b.dir, &korp_declared(), &b.passwd);
assert_eq!(scan.conflicts.len(), 1, "got {:?}", scan.conflicts);
assert_eq!(scan.conflicts[0].key, "Environment:KORP_CASES");
assert_eq!(scan.conflicts[0].kind, ConflictKind::ForeignHome);
}
/// The `korp-server.service` fragment that was live in `/etc/systemd/system` on
/// oden, 2026-08-03, verbatim. The audit seam has to reconstruct korp's
/// declaration from THIS, with no `SystemdServiceSpec` in hand.
const KORP_FRAGMENT: &str = "\
[Unit]
Description=korp-server service
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=korp
Group=korp
WorkingDirectory=/home/korp/.korp/software/current/korp-server
Environment=DATA_DIR=/home/korp/.korp/data/korp-server
Environment=LOG_DIR=/home/korp/.korp/log/korp-server
Environment=KORP_SERVER_DATA=/home/korp/.korp/data/korp-server
Environment=KORP_SERVER_ADDR=127.0.0.1:50051
ExecStart=/home/korp/.korp/software/current/korp-server/korp-server
Restart=on-failure
RestartSec=2
NoNewPrivileges=true
[Install]
WantedBy=multi-user.target
";
/// ★ RED — the audit seam, run on the REAL oden pair (fragment + the three
/// drop-ins that were live), must reach the SAME verdict the install-time check
/// reaches. Before `declared_from_unit_file` existed there was no way to ask this
/// question at all without re-running the install.
#[test]
fn auditing_the_live_korp_unit_finds_the_override_without_a_spec() {
let tmp = tempfile::tempdir().unwrap();
let unit_dir = tmp.path().join("system");
std::fs::create_dir_all(&unit_dir).unwrap();
std::fs::write(unit_dir.join("korp-server.service"), KORP_FRAGMENT).unwrap();
let d = unit_dir.join("korp-server.service.d");
std::fs::create_dir_all(&d).unwrap();
std::fs::write(d.join("addr.conf"), ADDR).unwrap();
std::fs::write(d.join("cases.conf"), CASES).unwrap();
std::fs::write(d.join("rootless-podman.conf"), ROOTLESS_PODMAN).unwrap();
let passwd = tmp.path().join("passwd");
std::fs::write(&passwd, PASSWD).unwrap();
let audit = audit_unit_in(&unit_dir, "korp-server.service", &passwd)
.expect("read")
.expect("the fragment is there");
// Reconstructed declaration == what korp's own spec renders.
assert_eq!(audit.declared.user, "korp");
assert_eq!(audit.declared.group, "korp");
assert_eq!(
audit
.declared
.env
.iter()
.find(|(k, _)| k == "KORP_SERVER_ADDR")
.map(|(_, v)| v.as_str()),
Some("127.0.0.1:50051"),
"the fragment's own addr must be read, or the ENV OVERRIDE cannot be seen"
);
// …and therefore the same four contradictions the install-time check names.
assert_eq!(audit.scan.effective_user.as_deref(), Some("alice"));
let kinds: Vec<ConflictKind> = audit.scan.conflicts.iter().map(|c| c.kind).collect();
assert!(kinds.contains(&ConflictKind::User), "{kinds:?}");
assert!(kinds.contains(&ConflictKind::Group), "{kinds:?}");
assert!(kinds.contains(&ConflictKind::EnvOverride), "{kinds:?}");
assert_eq!(
kinds
.iter()
.filter(|k| **k == ConflictKind::ForeignHome)
.count(),
2,
"both /home/alice/.korp pins: {:?}",
audit.scan.conflicts
);
}
/// ★ GREEN — the SAME audit on the SAME fragment with the drop-in dir absent is
/// silent. Without this the verb is a rubber stamp that always says RED.
#[test]
fn auditing_a_unit_with_no_dropins_is_clean() {
let tmp = tempfile::tempdir().unwrap();
let unit_dir = tmp.path().join("system");
std::fs::create_dir_all(&unit_dir).unwrap();
std::fs::write(unit_dir.join("korp-server.service"), KORP_FRAGMENT).unwrap();
let passwd = tmp.path().join("passwd");
std::fs::write(&passwd, PASSWD).unwrap();
let audit = audit_unit_in(&unit_dir, "korp-server.service", &passwd)
.unwrap()
.unwrap();
assert!(!audit.scan.has_dropins());
assert!(!audit.scan.has_conflicts(), "{:?}", audit.scan.conflicts);
// A unit that is not installed here is not an error — it is None.
assert!(
audit_unit_in(&unit_dir, "absent.service", &passwd)
.unwrap()
.is_none()
);
}
/// **THE 2026-08-20 ARTEFACT ITSELF — a hand-written resource ceiling is now a
/// named conflict instead of an invisible one.**
///
/// `/etc/systemd/system/dwarves-server.service.d/10-bounded.conf` exists because
/// `ServiceSpec` had no memory axis: `MemoryHigh=16G` / `MemoryMax=24G` had nowhere
/// else to live after dwarves-server hit 33 GB RSS and took the box down with
/// `gunnar-service`. Now that the axis exists, that file is exactly the shape this
/// module was built for — a setting the install does not write, the uninstall does
/// not remove, and every `--refresh` renders around without knowing it is there.
///
/// RED direction: drop the `"MemoryHigh" | "MemoryMax"` arm from `scan_dropins_in`
/// and the scan reports the drop-in as harmless (`has_conflicts() == false`) while
/// the drop-in still decides what the service's real ceiling is — the exact silent
/// override the module exists to end.
#[test]
fn a_hand_written_memory_ceiling_is_named_as_a_conflict() {
let b = bed(&[(
"10-bounded.conf",
"[Service]\nMemoryHigh=16G\nMemoryMax=24G\n",
)]);
// The declaration as it stood on 2026-08-20: no ceiling of any kind.
let scan = scan_dropins_in(&b.dir, &korp_declared(), &b.passwd);
assert_eq!(
scan.conflicts.len(),
2,
"both ceilings are separate facts and both are unowned:\n{:?}",
scan.conflicts
);
let report = scan.conflict_report("korp-server");
assert!(
report.contains("MEMORY CEILING") && report.contains("16G") && report.contains("24G"),
"the report must name the setting AND both numbers:\n{report}"
);
assert!(
scan.conflicts
.iter()
.all(|c| c.kind == ConflictKind::MemoryCeiling),
"a ceiling is not a user/env override:\n{:?}",
scan.conflicts
);
}
/// **Ceilings are compared by BYTES, never by text.**
///
/// systemd reads `24G`, `24576M` and `25769803776` as one number. A scan that
/// string-compared them would report a contradiction where there is none, and a
/// conflict report with false positives in it is a report operators learn to skip
/// — which costs exactly the signal this module exists to produce.
///
/// RED direction: replace `same_ceiling` with `declared == value` and the
/// "agreeing spelling" half fails while the "genuinely different" half still passes
/// — a guard that fires on everything is as useless as one that fires on nothing.
#[test]
fn the_same_ceiling_in_another_spelling_is_not_a_conflict() {
let mut declared = korp_declared();
declared.memory_high = "16G".into();
declared.memory_max = "24G".into();
// A drop-in that AGREES, spelled in bytes and in MiB.
let agreeing = bed(&[(
"10-bounded.conf",
"[Service]\nMemoryHigh=16384M\nMemoryMax=25769803776\n",
)]);
let scan = scan_dropins_in(&agreeing.dir, &declared, &agreeing.passwd);
assert!(
!scan.has_conflicts(),
"same number, different spelling — not a contradiction:\n{:?}",
scan.conflicts
);
assert!(
scan.has_dropins(),
"…but the drop-in is still NAMED as present"
);
// A drop-in that genuinely raises the hard ceiling IS a conflict.
let raising = bed(&[("10-bounded.conf", "[Service]\nMemoryMax=48G\n")]);
let scan = scan_dropins_in(&raising.dir, &declared, &raising.passwd);
assert_eq!(scan.conflicts.len(), 1, "{:?}", scan.conflicts);
assert_eq!(scan.conflicts[0].declared, "24G");
assert_eq!(scan.conflicts[0].effective, "48G");
}
/// **The audit could not read `[Unit]` at all, so a whole section was unaudited.**
///
/// `parse_service_directives` filtered to `[Service]`, on the reasoning — written
/// in its own doc comment — that "a `[Unit]` `After=` cannot contradict `User=`".
/// That held for the rules of the day and stopped holding the moment an axis
/// landed whose keys live in `[Unit]`. The scan would list the drop-in file as
/// PRESENT and find nothing wrong inside it, which reads as a clean audit.
///
/// RED direction: point the scan back at `parse_service_directives` alone and
/// `a_hand_written_start_limit_is_named_as_a_conflict` finds zero conflicts in a
/// file that rewrites the outcome of every failure.
#[test]
fn directives_are_read_from_unit_as_well_as_service() {
let text = "[Unit]\nStartLimitBurst=99\nAfter=network.target\n\
\n[Service]\nRestartSec=2\n";
let svc = parse_service_directives(text);
assert_eq!(svc, vec![("RestartSec".to_string(), "2".to_string())]);
let unit = parse_directives_in(text, "[Unit]");
assert_eq!(
unit,
vec![
("StartLimitBurst".to_string(), "99".to_string()),
("After".to_string(), "network.target".to_string()),
]
);
// Section matching is case-insensitive, as systemd's is.
assert_eq!(parse_directives_in("[unit]\nAfter=x\n", "[Unit]").len(), 1);
// …and continuation handling is not lost by the generalisation.
let cont = parse_directives_in("[Unit]\nAfter=a.target \\\n b.target\n", "[Unit]");
assert_eq!(cont[0].1, "a.target b.target");
}
/// **A hand-written OOM SCORE re-ranks this service against its NEIGHBOURS.**
///
/// The only one of the four axes whose drop-in changes something about OTHER
/// services: moving this unit's score decides who the kernel reaches for when the
/// box is short, and none of the affected services can see the file that did it.
/// Exactly the 2026-08-20 shape, where every unit sat at the same score and
/// `gunnar-service` went down with the forge.
#[test]
fn a_hand_written_oom_score_is_named_as_a_conflict() {
// Undeclared spec ⇒ the unit sits at the manager's level with everything else,
// so a drop-in moving it is an override worth naming.
let declared = korp_declared();
let b = bed(&[("10-rank.conf", "[Service]\nOOMScoreAdjust=-900\n")]);
let scan = scan_dropins_in(&b.dir, &declared, &b.passwd);
assert_eq!(scan.conflicts.len(), 1, "{:?}", scan.conflicts);
assert_eq!(scan.conflicts[0].kind, ConflictKind::OomScoreAdjust);
assert_eq!(scan.conflicts[0].effective, "-900");
assert_eq!(ConflictKind::OomScoreAdjust.label(), "OOM SCORE");
// Declared, and the drop-in agrees in another legal spelling — not a conflict.
let mut declared = korp_declared();
declared.oom_score_adjust = "500".into();
let b = bed(&[("10-rank.conf", "[Service]\nOOMScoreAdjust=+500\n")]);
let scan = scan_dropins_in(&b.dir, &declared, &b.passwd);
assert!(
!scan.has_conflicts(),
"`+500` is the declared 500: {:?}",
scan.conflicts
);
assert!(
scan.has_dropins(),
"…but the drop-in is still NAMED as present"
);
// A genuine re-rank IS a conflict, and names both sides.
let b = bed(&[("10-rank.conf", "[Service]\nOOMScoreAdjust=-1000\n")]);
let scan = scan_dropins_in(&b.dir, &declared, &b.passwd);
assert_eq!(scan.conflicts.len(), 1);
assert_eq!(scan.conflicts[0].declared, "500");
assert_eq!(scan.conflicts[0].effective, "-1000");
}
/// **A hand-written START LIMIT is the same story, with the worst blast radius.**
///
/// The ceiling and the reaction change how a running service behaves. This one
/// changes what happens when it FAILS — the same spec, with one number moved by an
/// operator, flips between "parks in `failed` after five tries" and "retries
/// forever". Nothing looks different until the day it starts failing, and then it
/// does the opposite of what the spec says.
///
/// The empty-declaration case is the one that actually bit: with no declared limit
/// the unit inherits the box's `10s`/`5`, so a drop-in supplying either number has
/// decided the outcome on behalf of an install that never had an opinion. That is
/// exactly the state `dwarves-server.service` was in for 217830 restarts.
///
/// RED direction: drop the `StartLimitIntervalSec | StartLimitBurst` arm from
/// `scan_dropins_in` and every assertion here fails while the unit still installs.
#[test]
fn a_hand_written_start_limit_is_named_as_a_conflict() {
// (a) UNDECLARED spec, drop-in supplies a window — an override worth naming.
let declared = korp_declared();
let bed_a = bed(&[("10-nolimit.conf", "[Unit]\nStartLimitIntervalSec=0\n")]);
let scan = scan_dropins_in(&bed_a.dir, &declared, &bed_a.passwd);
assert_eq!(scan.conflicts.len(), 1, "{:?}", scan.conflicts);
assert_eq!(scan.conflicts[0].kind, ConflictKind::StartLimit);
assert_eq!(scan.conflicts[0].declared, "");
assert_eq!(scan.conflicts[0].effective, "0");
assert!(
scan.conflicts[0]
.directive
.contains("StartLimitIntervalSec=0"),
"the report must say WHICH of the two keys was written: {:?}",
scan.conflicts[0]
);
// (b) DECLARED, and the drop-in agrees in another legal spelling. systemd reads
// a bare integer as seconds, so `12`, `12s` and `12sec` are one value — a scan
// that string-compared them would cry wolf.
let mut declared = korp_declared();
declared.start_limit_interval = "12".into();
declared.start_limit_burst = "5".into();
for spelling in ["12", "12s", "12sec"] {
let b = bed(&[(
"10-limit.conf",
&format!("[Unit]\nStartLimitIntervalSec={spelling}\nStartLimitBurst=5\n"),
)]);
let scan = scan_dropins_in(&b.dir, &declared, &b.passwd);
assert!(
!scan.has_conflicts(),
"`{spelling}` is the declared 12s in another spelling:\n{:?}",
scan.conflicts
);
assert!(
scan.has_dropins(),
"…but the drop-in is still NAMED as present"
);
}
// (c) A drop-in that flips the OUTCOME is a conflict, and both keys report.
let b = bed(&[(
"10-limit.conf",
"[Unit]\nStartLimitIntervalSec=0\nStartLimitBurst=99\n",
)]);
let scan = scan_dropins_in(&b.dir, &declared, &b.passwd);
assert_eq!(scan.conflicts.len(), 2, "{:?}", scan.conflicts);
assert!(
scan.conflicts
.iter()
.all(|c| c.kind == ConflictKind::StartLimit)
);
assert_eq!(ConflictKind::StartLimit.label(), "START LIMIT");
}
/// **The SAME story one axis over — a hand-written OOM REACTION.**
///
/// `10-bounded.conf` was written because the ceiling had nowhere to live. The
/// reaction to hitting that ceiling had nowhere to live either, so the next
/// hand-written drop-in is `OOMPolicy=continue` — invisible to the install for
/// exactly the same three reasons: the install writes only the fragment, the
/// uninstall leaves the drop-in, and every `--refresh` renders around it.
///
/// RED direction: drop the `"OOMPolicy"` arm from `scan_dropins_in` and a drop-in
/// that changes what happens to the unit when the kernel reaps it reports as
/// harmless.
#[test]
fn a_hand_written_oom_policy_is_named_as_a_conflict() {
let b = bed(&[("20-oom.conf", "[Service]\nOOMPolicy=continue\n")]);
// The declaration as it stands with no reaction declared: whatever
// DefaultOOMPolicy= is on this box (MEASURED `stop`, systemd 259, t14s).
let scan = scan_dropins_in(&b.dir, &korp_declared(), &b.passwd);
assert_eq!(
scan.conflicts.len(),
1,
"the reaction is unowned and must be named:\n{:?}",
scan.conflicts
);
assert_eq!(scan.conflicts[0].kind, ConflictKind::OomPolicy);
let report = scan.conflict_report("korp-server");
assert!(
report.contains("OOM POLICY") && report.contains("continue"),
"the report must name the setting AND the value taking effect:\n{report}"
);
}
/// **A reaction is compared by MEANING, not by text — and an unparseable one is
/// never guessed at.**
///
/// Same rule `same_ceiling` applies to sizes: a drop-in that repeats the declared
/// policy is not a contradiction, a drop-in that changes it is, and a value
/// systemd itself would refuse (`OOMPolicy=Stop` — its parser is case-sensitive)
/// is reported rather than silently read as agreeing.
///
/// RED direction: compare with `==` on the raw strings and the "agrees" half still
/// passes while a spec-declared `stop` vs a drop-in `stop` with trailing
/// whitespace goes red for nothing.
#[test]
fn an_oom_policy_that_repeats_the_declaration_is_not_a_conflict() {
let mut declared = korp_declared();
declared.oom_policy = "stop".into();
let agreeing = bed(&[("20-oom.conf", "[Service]\nOOMPolicy=stop\n")]);
let scan = scan_dropins_in(&agreeing.dir, &declared, &agreeing.passwd);
assert!(
!scan.has_conflicts(),
"the same reaction, repeated, is not a contradiction:\n{:?}",
scan.conflicts
);
assert!(
scan.has_dropins(),
"…but the drop-in is still NAMED as present"
);
let differing = bed(&[("20-oom.conf", "[Service]\nOOMPolicy=kill\n")]);
let scan = scan_dropins_in(&differing.dir, &declared, &differing.passwd);
assert_eq!(scan.conflicts.len(), 1, "{:?}", scan.conflicts);
assert_eq!(scan.conflicts[0].declared, "stop");
assert_eq!(scan.conflicts[0].effective, "kill");
// A value systemd would refuse is NOT quietly treated as agreeing with the
// declaration — guessing what a broken directive means is worse than naming it.
let broken = bed(&[("20-oom.conf", "[Service]\nOOMPolicy=Stop\n")]);
let scan = scan_dropins_in(&broken.dir, &declared, &broken.passwd);
assert_eq!(scan.conflicts.len(), 1, "{:?}", scan.conflicts);
}
/// A fragment's OWN reaction is read back out of it, exactly as its ceiling is,
/// so a spec that declares `OOMPolicy=` and a drop-in that repeats it do not
/// produce a phantom conflict.
#[test]
fn a_fragment_carries_its_own_oom_policy_into_the_comparison() {
let d = declared_from_unit_text(
"[Service]\nUser=dwarves\nOOMPolicy=continue\nExecStart=/bin/true\n",
);
assert_eq!(d.oom_policy, "continue");
// A unit that declares no reaction gets the manager's DefaultOOMPolicy= —
// which is what makes ANY drop-in reaction an override worth naming.
let none = declared_from_unit_text("[Service]\nUser=dwarves\nExecStart=/bin/true\n");
assert!(none.oom_policy.is_empty());
}
/// A fragment's OWN ceiling is read back out of it, so a spec that declares
/// `MemoryHigh=`/`MemoryMax=` and a drop-in that repeats them do not produce a
/// phantom conflict — the same reason `Group=` defaults to `User=` above.
#[test]
fn a_fragment_carries_its_own_declared_ceiling_into_the_comparison() {
let d = declared_from_unit_text(
"[Service]\nUser=dwarves\nMemoryHigh=16G\nMemoryMax=24G\nExecStart=/bin/true\n",
);
assert_eq!(d.memory_high, "16G");
assert_eq!(d.memory_max, "24G");
// A unit with no ceiling declares none — which is what makes ANY drop-in
// ceiling an override worth naming.
let none = declared_from_unit_text("[Service]\nUser=dwarves\nExecStart=/bin/true\n");
assert!(none.memory_high.is_empty() && none.memory_max.is_empty());
}
/// A fragment that names only `User=` must NOT report a phantom GROUP OVERRIDE:
/// systemd defaults `Group=` to the user's primary group, and the reconstruction
/// has to default it the same way or every such unit goes red for nothing.
#[test]
fn a_fragment_without_group_defaults_to_the_user_not_to_empty() {
let d = declared_from_unit_text("[Service]\nUser=korp\nExecStart=/bin/true\n");
assert_eq!(d.user, "korp");
assert_eq!(
d.group, "korp",
"an absent Group= is the user's group, not \"\""
);
// …and the scan therefore stays quiet on a drop-in that restates it.
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("x.service.d");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("g.conf"), "[Service]\nGroup=korp\n").unwrap();
let passwd = tmp.path().join("passwd");
std::fs::write(&passwd, PASSWD).unwrap();
assert!(!scan_dropins_in(&dir, &d, &passwd).has_conflicts());
}
/// A fragment that assigns the same key twice keeps the LAST value — systemd's
/// own rule inside one file. Keeping the first would compare the drop-in against
/// a value that never takes effect, and report a conflict backwards.
#[test]
fn a_repeated_key_in_one_fragment_keeps_the_last_value() {
let d = declared_from_unit_text(
"[Service]\nUser=a\nUser=b\nEnvironment=K=1\nEnvironment=K=2\n",
);
assert_eq!(d.user, "b");
assert_eq!(
d.env.iter().filter(|(k, _)| k == "K").count(),
1,
"no duplicate slot"
);
assert_eq!(d.env.iter().find(|(k, _)| k == "K").unwrap().1, "2");
}
/// `unit_files_in` lists REGULAR `.service` files only. `/etc/systemd/system` is
/// mostly enablement symlinks into `/usr/lib` plus `→ /dev/null` masks, and an
/// audit that walked those would report on units no installer here ever wrote.
#[cfg(unix)]
#[test]
fn unit_files_skips_symlinks_and_non_services() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
std::fs::write(dir.join("real.service"), "[Service]\n").unwrap();
std::fs::write(dir.join("other.timer"), "[Timer]\n").unwrap();
std::os::unix::fs::symlink("/dev/null", dir.join("masked.service")).unwrap();
std::os::unix::fs::symlink(dir.join("real.service"), dir.join("enabled.service")).unwrap();
let names = unit_files_in(dir);
assert_eq!(names, vec!["real.service".to_string()], "got {names:?}");
}
/// `dropin_dir` builds systemd's own name and nothing else.
#[test]
fn dropin_dir_is_the_systemd_name() {
assert_eq!(
dropin_dir(Path::new("/etc/systemd/system"), "korp-server.service"),
PathBuf::from("/etc/systemd/system/korp-server.service.d")
);
}
}