Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/holger

server/cli/src/systemd_verbs.rs

//! **The systemd flavour** (lane B4, A31 §7.5): `holger install …` and
//! `holger service …`, Holger registered with systemd on a plain Linux host. Behind
//! the `systemd` feature (in the default, the exe flavour); the container and the
//! appliance build without it and link none of this.

use super::*;

// Skíðblaðnir (skidbladnir) — the reusable airgap-install engine. holger owns its
// own service spec (its real CLI verb is `start --config`) and its own artifact
// paths (holger-named, mirroring nornir's layout); the engine owns the whole
// systemd lifecycle (dual scope, unit render, install/remove/status). holger does
// NOT hand-roll any systemd logic — it extends the helper.
pub(crate) use skidbladnir_systemd::systemd::{
    install_systemd_scoped, remove_systemd_scoped, status_systemd_scoped, systemd_unit_scoped,
    RestartPolicy, Scope, SystemdServiceSpec,
};
// The ship's single lifecycle seam: holger declares a backend-neutral `ServiceSpec`
// (WHAT it is) and hands it to `Lifecycle` (HOW it runs — install/start/stop/status/
// health), so holger carries the SAME lifecycle as nornir/dwarfs and hand-rolls no
// systemd. `AgentMode::Fat` marks holger as a warehouse-holding node (thin holger
// nodes would route to a fat peer — inherited from the ship for free).
pub(crate) use skidbladnir_systemd::service::{AgentMode, Lifecycle, ServiceSpec};

#[derive(Subcommand)]
pub(crate) enum InstallOp {
    /// Install (or update — idempotent) the `holger.service` unit + start it.
    /// Default scope follows privilege (`user` when non-root, `system` when root).
    Add(SvcArgs),
    /// Alias for `add` — the engine writes-or-overwrites the unit either way.
    Update(SvcArgs),
    /// Stop + remove the `holger.service` unit. Keeps the service user + data
    /// unless `--purge` (clean slate: also erases the scope's data tree, and in
    /// system scope the staged binary). `--purge` ALWAYS keeps the ssh deploy key
    /// and the token; `--wipe-identity` is the explicit opt-in that destroys them.
    Remove(SvcArgs),
    /// Print whether `holger.service` is active.
    Status(SvcArgs),
    /// Render the unit file that WOULD be installed, then stop. No root (dry-run).
    Render(SvcArgs),
}

/// Lifecycle verbs driven through the ship's `skidbladnir::service::Lifecycle`.
/// Each builds `holger_spec()` (the neutral `ServiceSpec`) and delegates — no
/// systemd is hand-rolled here.
#[derive(Subcommand)]
pub(crate) enum ServiceOp {
    /// Render + write the `holger.service` unit, `daemon-reload`, enable at boot,
    /// and start it now (`Lifecycle::install`). Stages the binary in system scope.
    Install(SvcArgs),
    /// Start the unit now (`Lifecycle::start`).
    Start(SvcArgs),
    /// Stop the unit (`Lifecycle::stop`).
    Stop(SvcArgs),
    /// Print whether the unit is active (`Lifecycle::status`).
    Status(SvcArgs),
    /// Actively probe the service's health (`Lifecycle::health` — with no explicit
    /// probe declared it reports whether the unit is active).
    Health(SvcArgs),
    /// Stop + disable the unit and remove its unit file (`Lifecycle::uninstall`);
    /// the service user + data are kept unless `--purge`, which erases the scope's
    /// data tree while KEEPING the ssh deploy key + token (`--wipe-identity`
    /// destroys those too).
    Uninstall(SvcArgs),
}

/// Service scope for `holger-server install`. Defaults to `user` when not root and
/// `system` when root — the clap mirror of `skidbladnir_systemd::systemd::Scope`.
#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
pub(crate) enum ScopeArg {
    /// `systemctl --user` unit running as you, from `~/.cargo/bin`, state in
    /// `~/.holger`. No root.
    User,
    /// System unit (root) running as the `holger` user, binary in `/usr/local/bin`,
    /// state under `/home/holger/.holger`.
    System,
}

impl ScopeArg {
    pub(crate) fn to_engine(self) -> Scope {
        match self {
            ScopeArg::User => Scope::User,
            ScopeArg::System => Scope::System,
        }
    }
}

/// Knobs for the rendered `holger.service`. Holger owns this spec + its paths; the
/// engine is generic. All paths default scope-aware (holger-named, mirroring
/// nornir) when omitted.
#[derive(Args)]
pub(crate) struct SvcArgs {
    /// Service scope. `user` = a `systemctl --user` unit running as you (no root);
    /// `system` = a root unit running as the `holger` user. Default: `user` when
    /// not root, `system` when root.
    #[arg(long, value_enum)]
    pub(crate) scope: Option<ScopeArg>,
    /// Binary `ExecStart=` runs. Default: this running executable — execed in place
    /// (user scope) or staged to `/usr/local/bin/holger-server` (system scope).
    #[arg(long)]
    pub(crate) bin: Option<PathBuf>,
    /// `--config` path the service starts with (Holger config is RON). Default:
    /// `<data>/holger.ron`.
    #[arg(long)]
    pub(crate) config: Option<PathBuf>,
    /// `WorkingDirectory=` + the service user's home (the data root). Default:
    /// `~/.holger` (user scope) / `/home/holger/.holger` (system scope).
    #[arg(long)]
    pub(crate) data: Option<PathBuf>,
    /// System user to create + run as (system scope only; default `holger`).
    #[arg(long, default_value = "holger")]
    pub(crate) user: String,
    /// `remove` / `service uninstall` ONLY: NUKE THE DATA, KEEP THE KEYS. Wipes the
    /// scope's data tree — `~/.holger` under user scope, `/home/holger` (which holds
    /// `/home/holger/.holger`) under system scope: archives, audit log, autogen
    /// config, caches and registry state. System scope also removes the staged
    /// `/usr/local/bin/holger-server` binary.
    ///
    /// The ssh deploy key (`.ssh`) and any stored token SURVIVE — always, in BOTH
    /// scopes. The `holger` service account and its home are kept too, so the key
    /// stays owned and a reinstall reuses it without re-registering anything on the
    /// git remotes. Destroying the identity is a separate, explicit opt-in:
    /// `--wipe-identity`. Mirrors `nornir install remove --purge`.
    #[arg(long)]
    pub(crate) purge: bool,
    /// DESTRUCTIVE OPT-IN — only valid together with `--purge`: ALSO destroy this
    /// node's IDENTITY, i.e. the `.ssh` deploy key and any stored token (and, in
    /// system scope, the `holger` service account itself).
    ///
    /// THE DEPLOY KEY REGISTERED ON THE GIT REMOTES WILL BE DESTROYED. It cannot be
    /// recovered: a reinstall generates a NEW key that you must register on every
    /// remote again before holger can pull. Without this flag `--purge` always keeps
    /// the key and the token.
    #[arg(long, requires = "purge")]
    pub(crate) wipe_identity: bool,
}

/// The holger system user's home — mirrors nornir's `/home/nornir`.
pub(crate) const HOLGER_SYSTEM_HOME: &str = "/home/holger";

/// Resolve the data root for `scope` (holger-named, nornir-mirrored): `~/.holger`
/// in user scope, `/home/holger/.holger` in system scope. An explicit `--data`
/// overrides.
pub(crate) fn resolve_data(scope: Scope, a: &SvcArgs) -> Result<PathBuf> {
    if let Some(d) = &a.data {
        return Ok(d.clone());
    }
    let home: PathBuf = match scope {
        Scope::User => std::env::var_os("HOME")
            .map(PathBuf::from)
            .context("$HOME is not set — needed to derive the user-scope data root")?,
        Scope::System => PathBuf::from(HOLGER_SYSTEM_HOME),
    };
    Ok(home.join(".holger"))
}

/// **The config the unit starts with** (lane OX-HOLFLAV): the unit runs
/// `start --config <data>/holger.ron`, so the install lays that file when the data
/// root has none. Returns the path it laid, or `None` when it laid nothing.
///
/// The file is the first start's ([`super::first_start_config`]): two local
/// registries (`/cache`, `/sparring`) on loopback, and in the default build no
/// remote at all, so the installed service dials nothing. An existing file is the
/// operator's and is never touched; `--config` names the operator's own file, so
/// nothing is laid then. In system scope the engine's `ensure_system_user` gives
/// the data root (and this file) to the `holger` user right after.
pub(crate) fn lay_service_config(a: &SvcArgs, data: &Path) -> Result<Option<PathBuf>> {
    if a.config.is_some() || data.join("holger.ron").exists() {
        return Ok(None);
    }
    super::first_start_config(data).map(Some)
}

/// The staged system-scope binary — product, not identity, so `--purge` removes it.
pub(crate) const HOLGER_SYSTEM_BIN: &str = "/usr/local/bin/holger-server";

/// **IDENTITY, never data.** Direct children of a holger service tree that carry the
/// node's identity rather than its content: the `.ssh` git deploy key registered on
/// the private remotes, and any stored access token.
///
/// **The owner's law: uninstalling the software NUKES ALL DATA but KEEPS THE KEYS.**
/// `--purge` skips every name in this list, in both scopes, unconditionally. Only the
/// explicit `--wipe-identity` opt-in destroys them. This is holger's twin of the
/// `.ssh` skip list inside [`skidbladnir_systemd::systemd::purge_service_data_keep_keys`],
/// widened by the token entry that the shared helper's `.ssh`-only rule does not
/// cover — the DELETE decision itself is still the one shared authority
/// ([`skidbladnir_systemd::systemd::guarded_remove_dir_all`]), never re-implemented here.
pub(crate) const IDENTITY_ENTRIES: &[&str] = &[".ssh", "token", ".token", "holger.token"];

/// What a `--purge` actually did — collected first, printed second, so the CLI can
/// report HONESTLY (and stay silent about success when nothing was removed) and so a
/// test can assert on the outcome without scraping stdout.
#[derive(Default, Debug)]
pub(crate) struct PurgeReport {
    /// Data paths erased (each through skidbladnir's guarded delete).
    pub(crate) wiped: Vec<PathBuf>,
    /// Identity paths deliberately left byte-intact (`.ssh`, token).
    pub(crate) identity_kept: Vec<PathBuf>,
    /// Identity paths destroyed — only ever non-empty under `--wipe-identity`.
    pub(crate) identity_wiped: Vec<PathBuf>,
    /// Paths the guard (or the filesystem) refused to remove.
    pub(crate) failed: usize,
    /// Anti-rollback floors (`holger-update-state` directories) that this purge
    /// DESTROYED, named one by one. Collected before the erase because afterwards
    /// there is nothing left to name. See [`ROLLBACK_STATE_DIR`].
    pub(crate) rollback_floors_destroyed: Vec<PathBuf>,
}

/// The directory name the Secure Update Gateway keeps its anti-rollback markers in
/// (`server/cli/src/secure_update.rs`, `Store::state`).
pub(crate) const ROLLBACK_STATE_DIR: &str = "holger-update-state";

/// Find every anti-rollback floor under `root`, so a purge can NAME what it is
/// about to destroy.
///
/// ?27, answered 2026-09-20: `wipe` MAY erase the floor — it is data in the data
/// tree, and pretending otherwise would mean a purge that leaves state behind
/// under a name the operator did not ask to keep. What it may NOT do is erase it
/// in silence, and there are now two ends to that:
///
/// * here, the purge says which floors it destroyed, by path;
/// * and at the other end, `bundle import` REFUSES against an erased floor unless
///   an admin authorizes the first import (`holger bundle allow-first-import`, lane
///   HOL-CDS; `update_bundle::rollback::Floor`).
///
/// The second half is the load-bearing one. An announcement can be missed; a
/// refusal cannot. ⚠ The bundle's own CMS chain does not cover this: it is not
/// rollback-aware, so a correctly signed OLD bundle passes forever. The floor is
/// the only downgrade stop there is.
#[cfg(unix)]
pub(crate) fn find_rollback_floors(root: &Path, out: &mut Vec<PathBuf>) {
    let Ok(rd) = std::fs::read_dir(root) else {
        return;
    };
    for entry in rd.flatten() {
        let path = entry.path();
        // Never follow a link out of the tree — the same rule the guarded delete
        // applies, for the same reason.
        if std::fs::symlink_metadata(&path)
            .map(|m| m.file_type().is_symlink())
            .unwrap_or(true)
        {
            continue;
        }
        if !path.is_dir() {
            continue;
        }
        if path.file_name().and_then(|n| n.to_str()) == Some(ROLLBACK_STATE_DIR) {
            out.push(path);
            continue; // the floor itself; no need to descend into it
        }
        find_rollback_floors(&path, out);
    }
}

impl PurgeReport {
    /// Did the purge actually erase anything? A purge that removed NOTHING must not
    /// print a success line.
    pub(crate) fn removed_anything(&self) -> bool {
        !self.wiped.is_empty() || !self.identity_wiped.is_empty()
    }
}

/// The roots a `--purge` may erase under `scope`, **scope-aware** — never a hardcoded
/// constant that is wrong for the scope actually in use:
///
/// * **user scope** — the data root alone (`~/.holger`, or an explicit `--data`). The
///   human's own `~/.ssh` sits OUTSIDE it and is never even enumerated.
/// * **system scope** — the `holger` service home `/home/holger`, which CONTAINS the
///   default data root `/home/holger/.holger`; wiping its children clears the data
///   while `/home/holger/.ssh` is skipped by name and the home itself is never
///   removed. An explicit `--data` outside that home is added as a second root.
pub(crate) fn purge_roots(scope: Scope, data: &Path) -> Vec<PathBuf> {
    let mut roots: Vec<PathBuf> = Vec::new();
    if scope == Scope::System {
        roots.push(PathBuf::from(HOLGER_SYSTEM_HOME));
    }
    if !roots.iter().any(|r| data.starts_with(r)) {
        roots.push(data.to_path_buf());
    }
    roots
}

/// Destroy ONE identity entry — reachable only via the explicit `--wipe-identity`
/// opt-in. The shared [`skidbladnir_systemd::systemd::guarded_remove_dir_all`] cannot be used
/// here BY DESIGN: it hard-refuses any `.ssh` component because key material is never
/// data. So the opt-in does the delete itself, under its own narrow guard — the target
/// must be a DIRECT child of the declared root, must carry an [`IDENTITY_ENTRIES`]
/// name, and is never followed through a symlink out of the tree.
#[cfg(unix)]
pub(crate) fn wipe_identity_entry(path: &Path, root: &Path) -> Result<()> {
    if path.parent() != Some(root) {
        anyhow::bail!(
            "REFUSING to wipe {}: not a direct child of {}",
            path.display(),
            root.display()
        );
    }
    let name = path
        .file_name()
        .map(|n| n.to_string_lossy().into_owned())
        .unwrap_or_default();
    if !IDENTITY_ENTRIES.contains(&name.as_str()) {
        anyhow::bail!("REFUSING to wipe {}: not an identity entry", path.display());
    }
    let md = std::fs::symlink_metadata(path)
        .with_context(|| format!("stat identity target {}", path.display()))?;
    if md.file_type().is_symlink() {
        anyhow::bail!(
            "REFUSING to wipe {}: it is a symlink — never follow a link out of the tree",
            path.display()
        );
    }
    if md.is_dir() {
        std::fs::remove_dir_all(path)
            .with_context(|| format!("wipe identity dir {}", path.display()))
    } else {
        std::fs::remove_file(path).with_context(|| format!("wipe identity file {}", path.display()))
    }
}

/// **The holger `--purge` data-erase: NUKE ALL DATA, KEEP THE KEYS.** Wipes every
/// direct child of `root` EXCEPT the [`IDENTITY_ENTRIES`], each erase routed through
/// [`skidbladnir_systemd::systemd::guarded_remove_dir_all`] — the ONE post-incident authority
/// that refuses any `.ssh` component, a real user's home, a system directory, a path
/// outside the declared `root`, and a symlink out of the tree. The same primitive
/// [`skidbladnir_systemd::systemd::purge_service_data_keep_keys`] delegates to; holger calls
/// it per entry only so the token joins `.ssh` on the keep list and so the report can
/// name what it touched.
///
/// `uid` is the account that OWNS the tree (its own home stays allowed by the guard).
/// `wipe_identity` flips the key + token from KEPT to DESTROYED — the explicit opt-in,
/// never the default. Best-effort: a refusal is reported, not fatal, and a missing
/// root is simply nothing to do.
#[cfg(unix)]
pub(crate) fn purge_tree_keep_identity(
    root: &Path,
    uid: u32,
    wipe_identity: bool,
    report: &mut PurgeReport,
) {
    use skidbladnir_systemd::systemd::guarded_remove_dir_all;
    let Ok(rd) = std::fs::read_dir(root) else {
        return; // absent / unreadable ⇒ nothing to erase
    };
    let mut children: Vec<PathBuf> = rd.flatten().map(|e| e.path()).collect();
    children.sort(); // deterministic order ⇒ deterministic output
    for p in children {
        let name = p
            .file_name()
            .map(|n| n.to_string_lossy().into_owned())
            .unwrap_or_default();
        if IDENTITY_ENTRIES.contains(&name.as_str()) {
            if !wipe_identity {
                report.identity_kept.push(p); // KEEP THE KEYS — untouched, byte-intact
                continue;
            }
            match wipe_identity_entry(&p, root) {
                Ok(()) => report.identity_wiped.push(p),
                Err(e) => {
                    eprintln!(
                        "holger: --wipe-identity left {} in place: {e:#}",
                        p.display()
                    );
                    report.failed += 1;
                }
            }
            continue;
        }
        match guarded_remove_dir_all(&p, uid, Some(root)) {
            Ok(()) => report.wiped.push(p),
            Err(e) => {
                eprintln!("holger: purge left {} in place: {e:#}", p.display());
                report.failed += 1;
            }
        }
    }
}

/// The uid that OWNS the tree being purged, so the guard treats that account's own
/// home as allowed instead of a foreign home to refuse: the `holger` service account
/// in system scope, the INVOKING human in user scope (whose `~/.holger` lives inside
/// his own `$HOME`).
#[cfg(unix)]
pub(crate) fn purge_owner_uid(scope: Scope, user: &str) -> u32 {
    let name = match scope {
        Scope::System => user.to_string(),
        Scope::User => whoami_or(""),
    };
    skidbladnir_systemd::systemd::lookup_system_uid(&name).unwrap_or(0)
}

/// Clean-slate `remove --purge` / `service uninstall --purge`: erase holger's DATA in
/// the scope actually in use, and KEEP the ssh deploy key + token (the owner's law).
/// Under `wipe_identity` — the explicit opt-in — the key, the token and (system scope)
/// the service account go too.
///
/// System scope deliberately does NOT `rm -rf /home/holger` and does NOT remove the
/// `holger` account on a plain `--purge`: the home HOLDS `/home/holger/.ssh`, and the
/// account OWNS it. Destroying the identity is never a side effect of removing a
/// service account here — it happens only when asked for by name.
#[cfg(unix)]
pub(crate) fn purge_holger_data(
    scope: Scope,
    data: &Path,
    user: &str,
    wipe_identity: bool,
) -> PurgeReport {
    let uid = purge_owner_uid(scope, user);
    let mut report = PurgeReport::default();
    let roots = purge_roots(scope, data);
    // Name the anti-rollback floors BEFORE erasing them — afterwards there is
    // nothing left to name (?27).
    for root in &roots {
        find_rollback_floors(root, &mut report.rollback_floors_destroyed);
    }
    for root in &roots {
        purge_tree_keep_identity(root, uid, wipe_identity, &mut report);
    }
    // Only claim destruction for floors that are actually gone.
    report.rollback_floors_destroyed.retain(|p| !p.exists());
    // The data root itself, once emptied — but only when it is not the service home
    // (which must survive to hold `.ssh`) and only when nothing identity-bearing is
    // still inside it. Guarded like every other delete.
    let data_root_now_empty = data != Path::new(HOLGER_SYSTEM_HOME)
        && std::fs::read_dir(data)
            .map(|mut d| d.next().is_none())
            .unwrap_or(false);
    if data_root_now_empty
        && skidbladnir_systemd::systemd::guarded_remove_dir_all(data, uid, Some(data)).is_ok()
    {
        report.wiped.push(data.to_path_buf());
    }
    if scope == Scope::System {
        // The staged binary is product, not identity.
        if std::fs::remove_file(HOLGER_SYSTEM_BIN).is_ok() {
            report.wiped.push(PathBuf::from(HOLGER_SYSTEM_BIN));
        }
        if wipe_identity {
            // Only now — removing the account is what orphans the key, so it is part
            // of the identity wipe, never of a data purge. `userdel` WITHOUT `-r`:
            // the home stays, the key inside it has already been destroyed above.
            // A32-14, a process by design: the host install's identity wipe removes the box's own account with the system's own tool (shadow and group files under its locks); a host verb, never in a box's serving path.
            let status = std::process::Command::new("userdel").arg(user).status();
            record_userdel(user, status, &mut report);
        }
    }
    report
}

/// Record the outcome of `userdel <user>` in the purge report. Only an exit 0
/// claims the account gone; a non-zero exit or a `userdel` that could not be
/// started is logged and counted as a refusal, like every other path the purge
/// left in place.
pub(crate) fn record_userdel(
    user: &str,
    status: std::io::Result<std::process::ExitStatus>,
    report: &mut PurgeReport,
) {
    match status {
        Ok(s) if s.success() => report
            .identity_wiped
            .push(PathBuf::from(format!("user `{user}`"))),
        Ok(s) => {
            eprintln!("holger: --wipe-identity left user `{user}` in place: userdel {s}");
            report.failed += 1;
        }
        Err(e) => {
            eprintln!(
                "holger: --wipe-identity left user `{user}` in place: userdel could not run: {e}"
            );
            report.failed += 1;
        }
    }
}

/// Print exactly what the purge did — no more, no less. A purge that erased NOTHING
/// gets no success line; a purge that kept the identity says so out loud, and one that
/// destroyed it says THAT out loud.
pub(crate) fn print_purge_report(scope: Scope, data: &Path, wipe_identity: bool, r: &PurgeReport) {
    if r.removed_anything() {
        println!(
            "  purged     : {} item(s) — holger data erased",
            r.wiped.len()
        );
        for p in &r.wiped {
            println!("                 {}", p.display());
        }
    } else {
        println!(
            "  purged     : nothing — no holger data found under {} ({scope:?} scope)",
            data.display()
        );
    }
    if r.failed > 0 {
        println!(
            "  refused    : {} path(s) left in place (see the messages above)",
            r.failed
        );
    }
    if !r.rollback_floors_destroyed.is_empty() {
        println!(
            "  FLOOR GONE : {} anti-rollback floor(s) DESTROYED",
            r.rollback_floors_destroyed.len()
        );
        for p in &r.rollback_floors_destroyed {
            println!("                 {}", p.display());
        }
        println!(
            "               nothing now records which bundle sequences were accepted \
             here. The next `holger-server bundle import` into this store REFUSES \
             until an admin runs `bundle allow-first-import` for that bundle, because an absent floor admits an OLD \
             bundle as readily as a new one and the bundle signature does not stop it."
        );
    }
    if wipe_identity {
        if r.identity_wiped.is_empty() {
            println!("  identity   : nothing to destroy — no ssh key or token was present");
        } else {
            println!(
                "  DESTROYED  : {} identity item(s) — --wipe-identity",
                r.identity_wiped.len()
            );
            for p in &r.identity_wiped {
                println!("                 {}", p.display());
            }
            println!(
                "               the deploy key registered on the git remotes is GONE; a \
                 reinstall needs a NEW key registered on every remote."
            );
        }
    } else if r.identity_kept.is_empty() {
        println!("  KEPT       : ssh key + token — none were present to keep");
    } else {
        println!(
            "  KEPT       : {} identity item(s) — byte-intact",
            r.identity_kept.len()
        );
        for p in &r.identity_kept {
            println!("                 {}", p.display());
        }
        println!(
            "               the ssh deploy key + token SURVIVE a purge; a reinstall \
             reuses them (pass --wipe-identity to destroy them too)."
        );
    }
}

/// Build Holger's own systemd spec for `scope`. Holger's real serve verb is
/// `start --config` (NOT `serve`), so it constructs the spec here and uses the
/// engine only to apply it — the right product/engine split. `exec_path` is the
/// final on-system binary path the unit's `ExecStart=` runs.
pub(crate) fn holger_service_spec(
    a: &SvcArgs,
    exec_path: &Path,
    data: &Path,
) -> SystemdServiceSpec {
    let data_s = data.to_string_lossy().into_owned();
    let config = a.config.clone().unwrap_or_else(|| data.join("holger.ron"));
    SystemdServiceSpec::new(
        "holger",
        "Holger artifact repository",
        exec_path.to_string_lossy(),
        &a.user, // ignored by the engine in user scope (no User=/Group= line)
        &data_s,
    )
    .with_arg("start")
    .with_arg("--config")
    .with_arg(config.to_string_lossy())
    .with_env("HOLGER_DATA", &data_s)
    .with_restart(RestartPolicy::OnFailure)
}

/// Holger's backend-neutral **ship** spec (`skidbladnir::service::ServiceSpec`) —
/// the single source of truth the `Lifecycle` runs. Same shape as
/// `holger_service_spec` (real verb is `start --config`, data-path env, on-failure
/// restart), expressed once through the neutral seam so holger inherits the SAME
/// lifecycle nornir/dwarfs have. `scope` (system vs `--user`) rides on the spec;
/// `AgentMode::Fat` marks holger as a warehouse-holding node (a thin holger node
/// would route to a fat peer — inherited from the ship, no new lifecycle code).
pub(crate) fn holger_spec(a: &SvcArgs, scope: Scope, exec_path: &Path, data: &Path) -> ServiceSpec {
    let data_s = data.to_string_lossy().into_owned();
    let config = a.config.clone().unwrap_or_else(|| data.join("holger.ron"));
    #[allow(unused_mut)]
    let mut spec = ServiceSpec::new(
        "holger",
        "Holger artifact repository",
        exec_path.to_string_lossy(),
    )
    .scope(scope)
    .user(a.user.as_str()) // omitted by the renderer in user scope (runs as you)
    .working_dir(data_s.as_str())
    .arg("start")
    .arg("--config")
    .arg(config.to_string_lossy())
    .env("HOLGER_DATA", data_s.as_str())
    .restart(RestartPolicy::OnFailure)
    .agent(AgentMode::Fat);
    // THE declared data root — the ONE `Lifecycle::uninstall` reads. Without it the
    // ship answers "declares NO data root … NOTHING was erased" and erases 0 items,
    // leaving the erase to whatever bespoke branch the product runs afterwards.
    //
    // `data` is ALREADY holger's resolved data root (the same value the unit's
    // `HOLGER_DATA=` and `WorkingDirectory=` carry), so the declaration reuses it
    // rather than inventing a second ladder — the resolver and the eraser cannot
    // disagree. One schema under either scope: the caller's home in user scope, the
    // `holger` account's home in system scope.
    #[cfg(unix)]
    match skidbladnir_systemd::uninstall::DataRoot::from_env("HOLGER_DATA", data) {
        Ok(dr) => spec = spec.data_root(&dr),
        Err(e) => eprintln!("holger: {e} — uninstall would erase nothing; not declaring a root"),
    }
    spec
}

/// Atomically install `src` to `dst` with mode 0755 (temp-write + rename on the
/// same dir). Used to stage the server binary into `/usr/local/bin` for system
/// scope (the `holger` user can't exec from your `~/.cargo/bin` / a removable
/// drive → systemd 203/EXEC).
#[cfg(unix)]
pub(crate) fn install_binary(src: &Path, dst: &Path) -> Result<()> {
    use std::os::unix::fs::PermissionsExt;
    if let Some(parent) = dst.parent() {
        std::fs::create_dir_all(parent).ok();
    }
    let tmp = dst.with_extension("new");
    std::fs::copy(src, &tmp)
        .with_context(|| format!("copy {} -> {}", src.display(), tmp.display()))?;
    std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o755))
        .with_context(|| format!("chmod 755 {}", tmp.display()))?;
    std::fs::rename(&tmp, dst)
        .with_context(|| format!("rename {} -> {}", tmp.display(), dst.display()))?;
    Ok(())
}

/// The binary the unit should exec, plus any staging side effect:
///   - user scope: exec your `--bin` (or this executable) in place — a user can
///     exec from their own cargo dir, so no copy.
///   - system scope: stage the binary into `/usr/local/bin/holger-server` and exec
///     it there.
pub(crate) fn resolve_exec(scope: Scope, a: &SvcArgs, stage: bool) -> Result<PathBuf> {
    let src = match &a.bin {
        Some(p) => p.clone(),
        None => std::env::current_exe().context("resolve this executable for --bin default")?,
    };
    match scope {
        Scope::User => Ok(src.canonicalize().unwrap_or(src)),
        Scope::System => {
            let dst = PathBuf::from(HOLGER_SYSTEM_BIN);
            if stage {
                if !src.exists() {
                    anyhow::bail!(
                        "holger-server binary not found at {} — build it, or pass --bin",
                        src.display()
                    );
                }
                install_binary(&src, &dst)
                    .with_context(|| format!("stage holger-server to {}", dst.display()))?;
            }
            Ok(dst)
        }
    }
}