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

nordisk/gunnar

crates/gunnar-cli/src/service.rs

//! `gunnar service`: the `systemd` integration. **Off by default.**
//!
//! Skidbladnir's systemd installer, taken as an optional dependency, copying
//! modgunn's shape (`modgunn/src/service.rs`): declare a
//! [`ServiceSpec`](skidbladnir::service::ServiceSpec), drive it through
//! [`Lifecycle`](skidbladnir::service::Lifecycle). That is the `Layout`-free
//! API. `product::SystemdInstaller` demands a version-dated layout and a
//! verified airgap bundle, and `product::registry()` hardcodes
//! holger/tunnr/nornir_server/knut_flight, so it is the wrong layer for gunnar.
//!
//! What it buys: unit rendering plus pure-Rust D-Bus control of
//! `org.freedesktop.systemd1`. **No `systemctl` subprocess**, which is the
//! only reason it is admissible at all under gunnar's zero-shell rule.
//!
//! # Three costs, recorded honestly
//!
//! RE-MEASURED 2026-08-04 (`cargo tree -e normal`, deduplicated by package, the
//! same counting `xtask`'s guard prints): `-p gunnar-cli --no-default-features
//! --features server` resolves **278** packages and `…server,systemd` **324**,
//! so the feature costs **+46**. The earlier "48 new crates / 32 / 16" split was
//! taken before the `fs` backend moved off gix porcelain and is superseded.
//!
//! 1. **A second async runtime, 28 of the 46.** `zbus` is non-optional in
//!    skidbladnir and resolves `async-io`, not tokio, so the smol stack
//!    (`zbus*`, `zvariant*`, `async-executor`, `polling`, `blocking`,
//!    `event-listener*`, …) sits beside gunnar's tokio.
//! 2. **12 crates of dead weight**: `tar`, `zstd*`, `blake3`, `uuid`, `xattr`,
//!    `filetime` are the airgap *packer*, which gunnar never calls. `zstd-sys`
//!    means this feature needs a C compiler and pkg-config — which the appliance
//!    shape, notably, does not.
//! 3. **Tension with Brokkr's "minimal dependencies"**: a sixth of the
//!    tree for a convenience. Justified only because it is default-off and the
//!    two shapes with no service manager never pay it. That is no longer a
//!    promise in prose: `xtask/src/guard.rs` bans `skidbladnir` outright from
//!    the appliance graph and witnesses the ban from its `service` profile.
//!
//! # Which shape this is
//!
//! `.nornir/build-variants.toml` names one cell per deployment shape, and this
//! module is compiled for exactly one of them — `gunnar-service`
//! (`--no-default-features --features server,systemd`, `kind = "service"`). The
//! container (`gunnar-image`) has no init to register with, and the OS-less
//! appliance (`gunnar-appliance-iso`) *is* the init; both chain the
//! `gunnar-server` artifact, which the guard proves systemd-free.
//!
//! # The version pin, and the uninstall that is thinner than it should be
//!
//! **0.1.12**, the newest publishable version. Local Skidbladnir is 0.1.13 and
//! depends on `nornir-escape`, which is not on crates.io, and gunnar must
//! build standalone from a fresh clone, so a path dep on `../Skidbladnir` is
//! not allowed even though all five existing consumers use one. gunnar is the
//! first registry-only consumer of this crate.
//!
//! 0.1.12 has **no `uninstall.rs`**: the `DataRoot` / `UninstallReport` /
//! guarded-delete purge law exists only in the unpublished 0.1.13. So
//! [`ServiceCommand::Uninstall`](crate::cli::ServiceCommand::Uninstall) is the
//! thinner `Result<()>`: it removes the unit and reports nothing about data.

use std::path::PathBuf;

use anyhow::{Context, Result};
use skidbladnir::service::{HealthProbe, Lifecycle, ServiceSpec, Socket};
use skidbladnir::systemd::{RestartPolicy, Scope};

use crate::cli::{ServiceArgs, ServiceCommand};

/// The unit name, and the dedicated system user the system-scope unit runs as.
pub const SERVICE_NAME: &str = "gunnar";

/// Where a **system-scope** unit keeps its data when the operator names no root.
///
/// `/var/lib/<name>` is the FHS answer and, more importantly, it is a CONSTANT
/// rather than a guess about an account. See [`resolved_data_root`].
pub const SYSTEM_DATA_ROOT: &str = "/var/lib/gunnar";

/// **The data root the unit will really use — always known, never implied.**
///
/// ⚠ This function exists because of a MEASURED failure, and the failure is not
/// gunnar's alone. `--data-root` is optional, and until 2026-08-04 an install
/// without it rendered a unit that named no root at all:
///
/// ```text
///   User=gunnar
///   Group=gunnar
///   WorkingDirectory=/
///   ExecStart=/usr/local/bin/gunnar serve --ssh-bind … --grpc-bind …
/// ```
///
/// Nothing there says where the repositories go, so the answer came from
/// `cli::default_data_root()` inside the *running* process: `$GUNNAR_DATA_ROOT`,
/// else `$HOME/.local/share/gunnar`, else the relative path `./gunnar-data`.
/// Under that unit both fall-throughs are wrong in a way that cannot be seen:
///
/// * with a `$HOME` for the `gunnar` account, the data lands in that account's
///   home, and NOTHING in the unit, the install output or `gunnar service unit`
///   mentions it;
/// * with no `$HOME` — which is what a system account made with
///   `--home /nonexistent` has — `./gunnar-data` resolves against
///   `WorkingDirectory=/` and gunnar writes **`/gunnar-data`**, at the root of
///   the filesystem.
///
/// This is korp's bug in a different repo: korp's `service_home` read the SCOPE
/// but never the ACCOUNT, so `--uninstall` looked in `/home/korp/.korp` for a
/// unit that had written 113 GB into the operator's own `~/.korp`. The output was
/// honest and the root was wrong. The general shape both share: **a capability
/// missing from the declaration becomes an assumption made somewhere else.**
///
/// The fix is by construction, not by a guard watching two answers agree — the
/// spec ALWAYS carries a root, so there is no second place for one to come from.
///
/// # Why system scope does not resolve `$HOME`
///
/// It would be resolving the WRONG home. `gunnar service install` runs as the
/// operator (or as root); the unit runs as `--run-as`. Reading `$HOME` here and
/// baking it into the unit is precisely how korp put a service's 113 GB into a
/// login account's home. Resolving `--run-as` out of `/etc/passwd` instead
/// (nornir's `service_home_in`) would be *correct*, but it makes the rendered
/// unit depend on the state of the box it was rendered on, and `gunnar service
/// unit` is meant to be printable anywhere. A constant under `/var/lib` is
/// declarative, is the same on every box, and is what `--data-root` exists to
/// override when an operator wants something else.
///
/// User scope is the opposite case and gets the opposite answer: a `--user` unit
/// really does run as the caller, so the caller's `$HOME` IS the right home, and
/// the resolver is reused from `cli` rather than restated here (one implementation).
pub fn resolved_data_root(args: &ServiceArgs) -> PathBuf {
    if let Some(root) = &args.data_root {
        return root.clone();
    }
    if args.user {
        crate::cli::default_data_root()
    } else {
        PathBuf::from(SYSTEM_DATA_ROOT)
    }
}

/// **The `RUST_LOG` the unit will really run with — always known, never
/// implied.**
///
/// The same shape as [`resolved_data_root`] and for the same reason: a
/// capability missing from the declaration becomes an assumption made
/// somewhere else. An `Environment=RUST_LOG=` the unit does not carry is not
/// "the operator's choice"; it is `logging::init`'s fall-through to a bare
/// `info`, decided inside the running process, recorded nowhere, and unable to
/// select a target whose level happens to sit below it.
pub fn resolved_log_filter(args: &ServiceArgs) -> String {
    args.log_filter
        .clone()
        .filter(|filter| !filter.trim().is_empty())
        .unwrap_or_else(gunnar_core::observe::service_log_filter)
}

/// How long `install` waits for the started unit to answer on its SSH port.
const HEALTH_TIMEOUT_MS: u64 = 5_000;

/// The drop-in file that carries [`HARDENING`] into the installed unit.
///
/// A drop-in rather than the unit body because the unit body is not gunnar's to
/// write: `Lifecycle::install` renders it from the spec and `std::fs::write`s it
/// itself. `<unit>.d/*.conf` is systemd's own mechanism for adding directives to
/// a unit somebody else renders, so it needs no fork of Skidbladnir and no
/// second renderer to drift from the first.
pub const HARDENING_DROPIN: &str = "10-hardening.conf";

/// **What the installed unit may do — the block `deploy/systemd/gunnar.service`
/// carries and Skidbladnir 0.1.12 cannot render.**
///
/// The shipped unit says of itself: *"`gunnar service install` writes the same
/// unit over D-Bus. This file is what it writes."* Until this block existed that
/// sentence was false, and falsifiably so: Skidbladnir's
/// `SystemdServiceSpec` has exactly ONE hardening field — `no_new_privileges`,
/// rendered as `NoNewPrivileges=true` — and no escape hatch for a directive it
/// does not model. So an operator who read `deploy/systemd/gunnar.service` and
/// then ran `gunnar service install` got a unit with **one** of the twenty-four
/// directives they had just read, and nothing said which.
///
/// That is the whole of the difference: an install with no `ProtectSystem=`
/// writes anywhere its uid can, an install with a non-empty
/// `CapabilityBoundingSet=` keeps every capability systemd hands a service, and
/// an install with no `SystemCallFilter=` can call `@privileged`. None of those
/// is a bug in gunnar's code; all of them are the deployment the repository
/// documents not being the deployment the repository installs.
///
/// The list is a `&[(key, value)]` and not a string so a duplicate key is
/// expressible (`SystemCallFilter=` is set twice, allow-list then subtraction,
/// which is systemd's own idiom) and so a test can iterate it.
pub const HARDENING: &[(&str, &str)] = &[
    ("NoNewPrivileges", "yes"),
    ("PrivateTmp", "yes"),
    ("PrivateDevices", "yes"),
    ("ProtectSystem", "strict"),
    ("ProtectHome", "yes"),
    ("ProtectKernelTunables", "yes"),
    ("ProtectKernelModules", "yes"),
    ("ProtectKernelLogs", "yes"),
    ("ProtectControlGroups", "yes"),
    ("ProtectClock", "yes"),
    ("ProtectHostname", "yes"),
    ("ProtectProc", "invisible"),
    ("RestrictSUIDSGID", "yes"),
    ("RestrictRealtime", "yes"),
    // Gunnar is only a git server: no build sandbox, so no namespaces either.
    ("RestrictNamespaces", "yes"),
    ("LockPersonality", "yes"),
    ("MemoryDenyWriteExecute", "yes"),
    ("RemoveIPC", "yes"),
    ("UMask", "0077"),
    ("CapabilityBoundingSet", ""),
    ("AmbientCapabilities", ""),
    ("SystemCallArchitectures", "native"),
    ("SystemCallFilter", "@system-service"),
    ("SystemCallFilter", "~@privileged @resources @obsolete"),
    ("RestrictAddressFamilies", "AF_INET AF_INET6 AF_UNIX"),
];

/// The hardening drop-in's text for `args`, including the one directive the
/// shipped unit does not need and this one does.
///
/// # `ReadWritePaths=`, and why it is derived rather than listed
///
/// `ProtectSystem=strict` makes the entire hierarchy read-only, and the shipped
/// unit gets its one writable place back from `StateDirectory=gunnar`, which
/// only ever means `/var/lib/gunnar`. `gunnar service install` takes
/// `--data-root`, so the writable place is whatever [`resolved_data_root`]
/// resolved — the same answer the unit's `Environment=GUNNAR_DATA_ROOT=` and
/// `WorkingDirectory=` already carry, taken from the same function rather than
/// guessed a second time. The directory exists by the time the unit starts:
/// Skidbladnir's `ensure_system_user` creates and chowns the spec's working
/// directory before the D-Bus `StartUnit`.
///
/// # Why `--user` scope drops `ProtectHome=`
///
/// A `--user` unit runs as the caller and [`resolved_data_root`] deliberately
/// answers with the caller's own `$HOME` there — that is the whole point of the
/// scope split. `ProtectHome=yes` makes `/home` inaccessible to the service, and
/// `ReadWritePaths=` does not unmask a path `ProtectHome=` has already hidden,
/// so keeping the line would install a unit that cannot reach its own
/// repositories. Every other directive is scope-neutral and stays.
pub fn hardening_dropin(args: &ServiceArgs) -> String {
    let mut text = String::from(
        "# Written by `gunnar service install`. The hardening half of\n\
         # deploy/systemd/gunnar.service, which the unit renderer cannot express.\n\
         [Service]\n",
    );
    for (key, value) in HARDENING {
        if args.user && *key == "ProtectHome" {
            continue;
        }
        text.push_str(key);
        text.push('=');
        text.push_str(value);
        text.push('\n');
    }
    text.push_str(&format!(
        "ReadWritePaths={}\n",
        resolved_data_root(args).display()
    ));
    text
}

/// Where the hardening drop-in goes, for the scope this install is in.
///
/// The same two directories Skidbladnir's own installer writes the unit into
/// (`systemd.rs:760-767`), because a drop-in systemd does not see beside its
/// unit is a file, not a policy.
pub fn dropin_dir(args: &ServiceArgs) -> Result<PathBuf> {
    if args.user {
        let home = std::env::var_os("HOME").map(PathBuf::from).ok_or_else(|| {
            anyhow::anyhow!("$HOME is not set, and a --user unit's drop-in lives under it")
        })?;
        Ok(home
            .join(".config/systemd/user")
            .join(format!("{SERVICE_NAME}.service.d")))
    } else {
        Ok(PathBuf::from("/etc/systemd/system").join(format!("{SERVICE_NAME}.service.d")))
    }
}

/// Write the hardening drop-in, returning where it landed.
///
/// **Before** `Lifecycle::install`, never after: `install` ends in a D-Bus
/// `StartUnit`, so a drop-in written afterwards would not apply to the process
/// that install just started — it would apply at the next restart, i.e. at the
/// next reboot, with nobody watching. That is the same shape as the `203`
/// failure [`preflight_exec`] exists to catch.
///
/// Mode 0644, deliberately: it carries no secret (the KEK lives in the 0600
/// `EnvironmentFile`, and this file names no environment at all), and systemd
/// reads it as root.
pub fn write_hardening_dropin(args: &ServiceArgs) -> Result<PathBuf> {
    let dir = dropin_dir(args)?;
    std::fs::create_dir_all(&dir)
        .with_context(|| format!("creating the drop-in directory {}", dir.display()))?;
    let path = dir.join(HARDENING_DROPIN);
    std::fs::write(&path, hardening_dropin(args))
        .with_context(|| format!("writing the hardening drop-in {}", path.display()))?;
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt as _;
        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644))
            .with_context(|| format!("setting the mode on {}", path.display()))?;
    }
    Ok(path)
}

/// The binary the unit will run, with the same default [`spec`] uses.
pub fn exec_path(args: &ServiceArgs) -> PathBuf {
    args.exec
        .clone()
        .unwrap_or_else(|| PathBuf::from("/usr/local/bin/gunnar"))
}

/// **`ExecStart` must exist and be executable before a unit naming it is
/// written.**
///
/// Nothing in Skidbladnir 0.1.12 stats this path: `SystemdServiceSpec::validate`
/// checks only that a spec carrying secrets also carries an `EnvironmentFile=`.
/// So a typo in `--exec` renders a valid unit, installs it, enables it and
/// reports success — and systemd's `StartUnit` is a queued job, so even the
/// start does not come back with the failure.
///
/// MEASURED 2026-08-06 with a deliberately wrong path: `✓ updated
/// gunnar systemd --user service`, exit 0, `is-active` = `active`. It failed at
/// the next restart with `ExecMainStatus=203`, `Result=exit-code` — i.e. it
/// would have failed at the next reboot, with nobody watching.
pub fn preflight_exec(exec: &std::path::Path) -> Result<()> {
    let meta = std::fs::metadata(exec).with_context(|| {
        format!(
            "the unit's ExecStart would be {}, and nothing is there. A unit naming a path \
             that does not exist installs cleanly, enables cleanly, and dies at the next \
             restart with status 203. Build and copy the binary first, or pass --exec.",
            exec.display()
        )
    })?;
    if !meta.is_file() {
        anyhow::bail!(
            "the unit's ExecStart would be {}, which is not a file",
            exec.display()
        );
    }
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt as _;
        if meta.permissions().mode() & 0o111 == 0 {
            anyhow::bail!(
                "the unit's ExecStart would be {}, which exists but carries no executable \
                 bit ({:o}). systemd would fail it with 203, the same way a missing file does",
                exec.display(),
                meta.permissions().mode() & 0o7777
            );
        }
    }
    Ok(())
}

/// Poll the declared [`HealthProbe`] until it answers, or the timeout expires.
///
/// A `bool`, not a `Result`: "the probe could not be run" and "the probe said
/// no" are the same answer to the only question the caller has, which is
/// whether it may tell an operator the service is up.
fn wait_until_healthy(lifecycle: &Lifecycle) -> bool {
    let deadline = std::time::Instant::now() + std::time::Duration::from_millis(HEALTH_TIMEOUT_MS);
    loop {
        if lifecycle.health().unwrap_or(false) {
            return true;
        }
        if std::time::Instant::now() >= deadline {
            return false;
        }
        std::thread::sleep(std::time::Duration::from_millis(200));
    }
}

/// The TCP port out of a `host:port`, or `fallback`.
pub fn addr_port(addr: &str, fallback: u16) -> u16 {
    addr.rsplit(':')
        .next()
        .and_then(|port| port.parse().ok())
        .unwrap_or(fallback)
}

/// Declare gunnar's service.
///
/// The health probe is the **SSH** port, not the gRPC one. gRPC is optional
/// (`--no-grpc`) and `Admin.Health` now needs a credential of its own (the
/// allow-all hole is closed); SSH is the listener that has to be up for gunnar
/// to be doing its job at all, so it is the one whose absence should mark the
/// service unhealthy.
pub fn spec(args: &ServiceArgs) -> ServiceSpec {
    let exec = args
        .exec
        .clone()
        .unwrap_or_else(|| PathBuf::from("/usr/local/bin/gunnar"));
    let scope = if args.user {
        Scope::User
    } else {
        Scope::System
    };
    let ssh_port = addr_port(&args.ssh_bind, 2222);
    let root = resolved_data_root(args);
    let root_s = root.to_string_lossy().into_owned();

    let mut spec = ServiceSpec::new(
        SERVICE_NAME,
        "gunnar — a git server over SSH, with a gRPC control plane",
        exec.to_string_lossy().as_ref(),
    )
    .scope(scope)
    .restart(RestartPolicy::OnFailure)
    .socket(Socket::tcp(ssh_port))
    .health(HealthProbe::Tcp(ssh_port))
    // Both of these, and both unconditionally. `Environment=` is what gunnar
    // actually reads; `WorkingDirectory=` is what makes the answer safe even if
    // it ever stopped reading it, because `cli::default_data_root`'s last
    // fall-through is the RELATIVE path `./gunnar-data` and the renderer's
    // default working directory is `/`. One of them tells gunnar where to go;
    // the other makes the place it would otherwise land somewhere survivable
    // instead of the root of the filesystem. See [`resolved_data_root`].
    .env("GUNNAR_DATA_ROOT", root_s.as_str())
    // ── the log, which a unit with no `Environment=RUST_LOG=` does not have ──
    //
    // A journal an operator cannot read is a server that is not observable, and
    // "no filter" is not neutral: `logging::init` falls through to a bare
    // `info`, which selects gunnar's targets only by accident of their level.
    // The 2026-08-05 `"gunnar.lfs"` finding is what that costs — seventeen call
    // sites emitting on a target no documented filter selected, silently.
    //
    // So the unit always CARRIES the filter, and the filter is derived from
    // `observe::TARGETS` rather than typed out here (one implementation).
    .env("RUST_LOG", resolved_log_filter(args).as_str())
    .working_dir(root_s.as_str());

    spec.args = vec![
        "serve".to_owned(),
        "--ssh-bind".to_owned(),
        args.ssh_bind.clone(),
        "--grpc-bind".to_owned(),
        args.grpc_bind.clone(),
    ];
    // A `--user` unit runs as whoever installed it; naming a user there is
    // meaningless and systemd rejects it.
    if !args.user {
        spec = spec.user(&args.run_as);
    }
    spec
}

/// The arguments every verb shares. Exhaustive by construction, so a new
/// variant is a compile error here too.
fn args_of(command: &ServiceCommand) -> &ServiceArgs {
    match command {
        ServiceCommand::Install(a)
        | ServiceCommand::Start(a)
        | ServiceCommand::Stop(a)
        | ServiceCommand::Status(a)
        | ServiceCommand::Uninstall(a)
        | ServiceCommand::Unit(a) => a,
    }
}

/// Run a `gunnar service …` verb.
///
/// **The dispatch matches the enum, not a `&str` derived from it**.
/// It used to lower the command to a verb string and re-match on that, with an
/// `unreachable!` on the fallthrough, so adding a variant and routing it in
/// the lowering step, which is the natural way to add one, compiled clean and
/// aborted at runtime. That was witnessed: a `Reload` variant reached
/// `unreachable!("unhandled service verb reload")`. Matching the enum with no
/// wildcard makes the same mistake a compile error, which is where it belongs.
/// Do not reintroduce a catch-all arm.
pub fn run(command: ServiceCommand) -> Result<()> {
    let lifecycle = Lifecycle::new(spec(args_of(&command)));
    match command {
        ServiceCommand::Unit(args) => {
            // Both halves, because both halves are installed. Printing only the
            // rendered unit is what made `deploy/systemd/gunnar.service`'s "this
            // file is what it writes" unfalsifiable from the CLI.
            print!("{}", lifecycle.unit_text());
            println!(
                "\n# {}",
                dropin_dir(&args)
                    .map(|dir| dir.join(HARDENING_DROPIN).display().to_string())
                    .unwrap_or_else(|why| format!("(drop-in path unknown: {why})"))
            );
            print!("{}", hardening_dropin(&args));
            Ok(())
        }
        ServiceCommand::Install(args) => {
            // ── 1. the path must exist BEFORE anything is written ───────────
            preflight_exec(&exec_path(&args))?;
            // ── 2. stop first, so `start` cannot be a no-op ─────────────────
            //
            // MEASURED 2026-08-06 and this is the whole reason the
            // three steps are here. `install()` ends in D-Bus `StartUnit`, and
            // `StartUnit` on a unit that is ALREADY active does nothing. So an
            // install over a running gunnar wrote a new unit, reloaded the
            // manager, printed `✓ installed`, exited 0 — and left the OLD
            // process running the OLD unit. With a deliberately wrong
            // `ExecStart` the output was still `✓ updated … installed
            // gunnar.service`, `systemctl is-active` still said `active`, and
            // `gunnar service status` still said `active` and exited 0. The
            // damage only appears at the next restart, i.e. at the next reboot,
            // as `ExecMainStatus=203` in a `Restart=on-failure` loop.
            //
            // `stop` is best-effort on purpose: a first install has no unit to
            // stop, and D-Bus says so as an error rather than as a no-op.
            let _ = lifecycle.stop();
            // ── 2b. what the process may do, before it is allowed to do it ──
            //
            // See [`write_hardening_dropin`]: `install()` ends in `StartUnit`,
            // so this has to be on disk first or the process it starts runs
            // unhardened until the next reboot.
            let dropin = write_hardening_dropin(&args)?;
            lifecycle.install().context("installing the gunnar unit")?;
            println!("hardened by {}", dropin.display());
            // ── 3. and it has to be ANSWERING, not merely `active` ──────────
            //
            // `HealthProbe::Tcp` on the SSH port: a real connect to the
            // listener. `active` is systemd's opinion about a process it
            // spawned; this is gunnar's own socket accepting.
            let healthy = wait_until_healthy(&lifecycle);
            println!("installed {}", lifecycle.unit());
            if !healthy {
                anyhow::bail!(
                    "{} was installed and started, but nothing is answering on its SSH port \
                     after {HEALTH_TIMEOUT_MS} ms. The unit is on disk and enabled; the \
                     process is not serving. Read the journal before trusting the install: \
                     `journalctl --user -u {} -n 50`",
                    lifecycle.unit(),
                    lifecycle.unit()
                );
            }
            println!(
                "{} is answering on {} — the running process, not just the unit file",
                lifecycle.unit(),
                args.ssh_bind
            );
            Ok(())
        }
        ServiceCommand::Start(_) => {
            lifecycle.start().context("starting gunnar")?;
            println!("started {}", lifecycle.unit());
            Ok(())
        }
        ServiceCommand::Stop(_) => {
            lifecycle.stop().context("stopping gunnar")?;
            println!("stopped {}", lifecycle.unit());
            Ok(())
        }
        ServiceCommand::Status(_) => {
            let active = lifecycle.status().context("querying gunnar's status")?;
            println!(
                "{} {}",
                lifecycle.unit(),
                if active { "active" } else { "inactive" }
            );
            // **The answer is the exit code too.** It printed `inactive` and
            // exited 0 until 2026-08-06, which is the same shape as a failing
            // command piped to `tail`: a script asking "is gunnar up?" got a
            // clean result from a dead server. MEASURED while the unit
            // was in a 203 crash loop: `gunnar.service inactive` / `exit=0`.
            // `systemctl is-active` has exited non-zero for this since 2010.
            if active {
                Ok(())
            } else {
                anyhow::bail!(
                    "{} is not active. `journalctl --user -u {} -n 50` says why",
                    lifecycle.unit(),
                    lifecycle.unit()
                )
            }
        }
        ServiceCommand::Uninstall(args) => {
            // Skidbladnir 0.1.12: a bare `Result<()>`. No UninstallReport, no
            // guarded purge; those are 0.1.13, which cannot be published.
            lifecycle.uninstall().context("removing the gunnar unit")?;
            // The drop-in is gunnar's file, so gunnar removes it. Best-effort
            // for the same reason the install's `stop` is: on a box that never
            // had one there is nothing to remove, and that is not a failure.
            if let Ok(dir) = dropin_dir(&args) {
                let _ = std::fs::remove_file(dir.join(HARDENING_DROPIN));
                let _ = std::fs::remove_dir(&dir);
            }
            println!(
                "removed {}; gunnar's data root was NOT touched (0.1.12 has no purge law)",
                lifecycle.unit()
            );
            Ok(())
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::cli::ServiceArgs;

    fn args() -> ServiceArgs {
        ServiceArgs {
            exec: Some(PathBuf::from("/usr/local/bin/gunnar")),
            ssh_bind: "0.0.0.0:2222".to_owned(),
            grpc_bind: "127.0.0.1:50051".to_owned(),
            user: false,
            run_as: "gunnar".to_owned(),
            data_root: Some(PathBuf::from("/var/lib/gunnar")),
            log_filter: None,
        }
    }

    #[test]
    fn addr_port_parses_or_falls_back() {
        assert_eq!(addr_port("0.0.0.0:2222", 1), 2222);
        assert_eq!(addr_port("127.0.0.1:50051", 1), 50051);
        assert_eq!(addr_port("garbage", 2222), 2222);
    }

    /// The rendered unit must carry gunnar's identity, its dedicated user, and
    /// the arguments that make it actually serve. A unit that renders but omits
    /// `serve` installs cleanly and does nothing.
    #[test]
    fn the_system_unit_renders_what_it_must() {
        let unit = Lifecycle::new(spec(&args())).unit_text();
        assert!(unit.contains("[Unit]"), "not a unit file:\n{unit}");
        assert!(
            unit.contains("ExecStart=/usr/local/bin/gunnar"),
            "no ExecStart:\n{unit}"
        );
        assert!(unit.contains("serve"), "the unit does not serve:\n{unit}");
        assert!(
            unit.contains("--grpc-bind 127.0.0.1:50051"),
            "the unit must keep gRPC on loopback:\n{unit}"
        );
        assert!(
            unit.contains("User=gunnar"),
            "gunnar runs as its own system user:\n{unit}"
        );
        assert!(
            unit.contains("Environment=GUNNAR_DATA_ROOT=/var/lib/gunnar"),
            "the unit must tell gunnar where its data is:\n{unit}"
        );
    }

    /// The identity-value guard for the test above: a `--user` unit must NOT
    /// carry `User=`, so the assertion there is discriminating rather than
    /// something the renderer emits unconditionally.
    #[test]
    fn a_user_scope_unit_names_no_user() {
        let mut args = args();
        args.user = true;
        let unit = Lifecycle::new(spec(&args)).unit_text();
        assert!(
            !unit.contains("User=gunnar"),
            "a --user unit runs as the caller and must not name a user:\n{unit}"
        );
    }

    /// **An install with no `--data-root` must still say where the data goes.**
    ///
    /// The regression this locks down was real and is written up on
    /// [`resolved_data_root`]: the unit rendered `WorkingDirectory=/`, no
    /// `Environment=GUNNAR_DATA_ROOT=`, and `User=gunnar`, so the running
    /// process fell through `cli::default_data_root` to the account's `$HOME`
    /// — unmentioned anywhere — or, with no `$HOME`, to the relative
    /// `./gunnar-data` against `/`, i.e. **`/gunnar-data`**.
    ///
    /// Both keys are asserted because they fail independently: `Environment=`
    /// is what gunnar reads, `WorkingDirectory=` is what the fall-through would
    /// resolve against if it ever stopped reading it.
    #[test]
    fn an_install_without_a_data_root_still_declares_one() {
        let mut a = args();
        a.data_root = None;
        let unit = Lifecycle::new(spec(&a)).unit_text();
        assert!(
            unit.contains("Environment=GUNNAR_DATA_ROOT=/var/lib/gunnar"),
            "a default system install names no data root, so gunnar picks one at \
             runtime and nothing records which:\n{unit}"
        );
        assert!(
            unit.contains("WorkingDirectory=/var/lib/gunnar"),
            "`./gunnar-data` would resolve against this, and `/` makes that \
             `/gunnar-data`:\n{unit}"
        );
    }

    /// The identity-value guard for the test above.
    ///
    /// `/var/lib/gunnar` is a constant, and a test that only ever sees a
    /// constant cannot tell a resolver that works from one that returns the
    /// same thing whatever it is asked. So this asserts the OTHER scope gets a
    /// DIFFERENT answer — the caller's own home — which is the whole
    /// distinction [`resolved_data_root`] exists to make. A `--user` unit runs
    /// as the caller, so the caller's `$HOME` is the right home there and the
    /// wrong one in system scope; if this test ever reads `/var/lib/gunnar`,
    /// the scope split has collapsed and the test above is proving nothing.
    #[test]
    fn user_scope_resolves_the_callers_home_and_not_the_system_root() {
        let mut a = args();
        a.data_root = None;
        a.user = true;
        let root = resolved_data_root(&a);
        assert_ne!(
            root,
            PathBuf::from(SYSTEM_DATA_ROOT),
            "a --user unit must not be pinned to the system state directory"
        );
        assert_eq!(
            root,
            crate::cli::default_data_root(),
            "a --user unit runs as the caller, so it must reuse the caller's own \
             resolver rather than a second copy of it"
        );
    }

    /// **A path that is not there must be refused before a unit names it.**
    ///
    /// The green half of this is the identity-value guard: without it,
    /// `preflight_exec` could return `Err` unconditionally and the two refusals
    /// below would both still pass.
    #[test]
    fn an_exec_that_cannot_run_is_refused_and_a_real_one_is_not() {
        let dir = tempfile::tempdir().expect("a temp dir");

        // ── absent ───────────────────────────────────────────────────────────
        let absent = dir.path().join("not-here");
        let err = preflight_exec(&absent).expect_err("an absent ExecStart must be refused");
        let text = format!("{err:#}");
        assert!(
            text.contains("not-here") && text.contains("203"),
            "the refusal must name the path and what systemd would do with it: {text}"
        );

        // ── present, but not executable ──────────────────────────────────────
        let plain = dir.path().join("not-executable");
        std::fs::write(&plain, b"#!/bin/sh\n").expect("write");
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt as _;
            std::fs::set_permissions(&plain, std::fs::Permissions::from_mode(0o644))
                .expect("chmod");
            let err =
                preflight_exec(&plain).expect_err("a non-executable ExecStart must be refused");
            let text = format!("{err:#}");
            assert!(
                text.contains("executable bit"),
                "a file with no +x is a different failure from an absent one, and must say \
                 so: {text}"
            );
        }

        // ── a real executable ────────────────────────────────────────────────
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt as _;
            std::fs::set_permissions(&plain, std::fs::Permissions::from_mode(0o755))
                .expect("chmod");
        }
        preflight_exec(&plain).expect("an executable file must be accepted");
    }

    /// The default `--exec` must be the same path in the preflight and in the
    /// rendered unit, or the check guards a path the unit does not name.
    #[test]
    fn the_preflight_checks_the_path_the_unit_will_actually_name() {
        let mut a = args();
        a.exec = None;
        let unit = Lifecycle::new(spec(&a)).unit_text();
        let checked = exec_path(&a);
        assert!(
            unit.contains(&format!("ExecStart={}", checked.display())),
            "the preflight would stat {} while the unit runs something else:\n{unit}",
            checked.display()
        );
    }

    /// **The unit must carry a log filter, and it must name gunnar's targets.**
    ///
    /// An installed service whose journal is empty and one that is not running
    /// look identical from outside. A unit with no `Environment=RUST_LOG=`
    /// leaves the answer to `logging::init`'s fall-through, where nothing
    /// records it and no target is named.
    #[test]
    fn the_unit_carries_a_log_filter_naming_every_gunnar_target() {
        let unit = Lifecycle::new(spec(&args())).unit_text();
        assert!(
            unit.contains("Environment=RUST_LOG="),
            "the unit names no log filter, so what it logs is decided inside the process \
             and written down nowhere:\n{unit}"
        );
        for target in gunnar_core::observe::TARGETS {
            assert!(
                unit.contains(&format!("{target}=")),
                "the unit's filter does not select {target}; that is the 2026-08-05 \
                 `gunnar.lfs` failure, one deployment later:\n{unit}"
            );
        }
    }

    /// The identity-value guard for the test above.
    ///
    /// `Environment=RUST_LOG=trace` would satisfy every assertion up there
    /// while telling an operator nothing about which targets exist — and
    /// `--log-filter` has to reach the unit at all, or the flag is decoration.
    #[test]
    fn an_explicit_log_filter_replaces_the_derived_one() {
        let mut a = args();
        a.log_filter = Some("warn,gunnar::audit=trace".to_owned());
        let unit = Lifecycle::new(spec(&a)).unit_text();
        assert!(
            unit.contains("Environment=RUST_LOG=warn,gunnar::audit=trace"),
            "--log-filter did not reach the unit:\n{unit}"
        );
        assert!(
            !unit.contains(gunnar_core::observe::TARGET_GIT),
            "the operator's filter was merged with the default instead of replacing it, \
             so they cannot turn a target OFF:\n{unit}"
        );
        // …and an empty one is not a filter. Without this, `--log-filter ''`
        // installs a unit whose `RUST_LOG=` is the empty string, which
        // `EnvFilter` reads as "off".
        a.log_filter = Some("   ".to_owned());
        let unit = Lifecycle::new(spec(&a)).unit_text();
        assert!(
            unit.contains(&format!(
                "Environment=RUST_LOG={}",
                gunnar_core::observe::service_log_filter()
            )),
            "a blank --log-filter must fall back to the derived one, not install an empty \
             RUST_LOG:\n{unit}"
        );
    }

    /// The hardening directives the repository SHIPS, read out of
    /// `deploy/systemd/gunnar.service` itself.
    ///
    /// Selected by subtraction, not by a second list of key names: a directive
    /// is "hardening" unless it is one of the ones that says who the unit is,
    /// where it lives or when it runs — which are exactly the ones the spec
    /// renderer owns. So a line added to the shipped unit tomorrow enters this
    /// set by itself, and a test that would otherwise only ever check the lines
    /// somebody remembered to copy here cannot go quietly stale.
    ///
    /// `EnvironmentFile=` is excluded on purpose: the KEK channel is not
    /// hardening, `gunnar service install` has never written one, and giving it
    /// one is a behaviour change rather than this finding.
    fn shipped_hardening() -> Vec<String> {
        const RENDERED_BY_THE_SPEC: &[&str] = &[
            "Description",
            "Documentation",
            "After",
            "Wants",
            "BindsTo",
            "Type",
            "ExecStart",
            "EnvironmentFile",
            "Environment",
            "User",
            "Group",
            "DynamicUser",
            "StateDirectory",
            "StateDirectoryMode",
            "WorkingDirectory",
            "Restart",
            "RestartSec",
            "WantedBy",
        ];
        let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
            .join("../../deploy/systemd/gunnar.service");
        let shipped = std::fs::read_to_string(&path)
            .unwrap_or_else(|why| panic!("reading {}: {why}", path.display()));
        shipped
            .lines()
            .map(str::trim)
            .filter(|line| {
                line.starts_with(|c: char| c.is_ascii_alphabetic()) && line.contains('=')
            })
            .filter(|line| {
                let key = line.split('=').next().unwrap_or_default();
                !RENDERED_BY_THE_SPEC.contains(&key)
            })
            .map(str::to_owned)
            .collect()
    }

    /// **`gunnar service install` must install the hardening the repository
    /// documents — every line of it.**
    ///
    /// `deploy/systemd/gunnar.service` says, in its own header, *"`gunnar
    /// service install` writes the same unit over D-Bus. This file is what it
    /// writes."* This test is that sentence, made falsifiable. MEASURED with the
    /// drop-in emptied: **all 25** of the shipped unit's hardening directives
    /// are missing from what the renderer alone produces. Even
    /// `NoNewPrivileges` — the single hardening field Skidbladnir 0.1.12's
    /// `SystemdServiceSpec` models — misses, because it renders `=true` where
    /// the shipped unit says `=yes`. Same setting, second spelling, and a second
    /// spelling is exactly what a parity test must not accept quietly.
    #[test]
    fn the_install_carries_every_hardening_line_the_repository_ships() {
        let shipped = shipped_hardening();
        assert!(
            shipped.len() >= 20,
            "the shipped unit yielded only {} directives, so the subtraction above is \
             swallowing the block this test exists to compare against: {shipped:?}",
            shipped.len()
        );

        let a = args();
        let installed = format!(
            "{}\n{}",
            Lifecycle::new(spec(&a)).unit_text(),
            hardening_dropin(&a)
        );
        let missing: Vec<&String> = shipped
            .iter()
            .filter(|line| !installed.contains(line.as_str()))
            .collect();
        assert!(
            missing.is_empty(),
            "an operator who read deploy/systemd/gunnar.service and then ran `gunnar \
             service install` would not get {missing:?}:\n{installed}"
        );
    }

    /// The identity-value guard for the test above, twice over.
    ///
    /// A drop-in that simply echoed the shipped file would satisfy it while
    /// installing a unit that cannot run. Two directives have to differ from a
    /// verbatim copy, and they fail independently:
    ///
    /// * `ReadWritePaths=` must name the root THIS install resolved, or
    ///   `ProtectSystem=strict` leaves a service with `--data-root` nowhere to
    ///   write — the shipped unit gets its one writable place from
    ///   `StateDirectory=`, which only ever means `/var/lib/gunnar`;
    /// * a `--user` unit must NOT carry `ProtectHome=`, whose whole effect is
    ///   to hide the `$HOME` that [`resolved_data_root`] just answered with.
    #[test]
    fn the_dropin_is_not_a_copy_of_the_shipped_unit() {
        let mut a = args();
        a.data_root = Some(PathBuf::from("/srv/gunnar"));
        let dropin = hardening_dropin(&a);
        assert!(
            dropin.contains("ReadWritePaths=/srv/gunnar"),
            "ProtectSystem=strict with no writable data root is a service that cannot \
             serve:\n{dropin}"
        );
        assert!(
            !dropin.contains("ReadWritePaths=/var/lib/gunnar"),
            "the writable path is the shipped unit's constant, not the root this install \
             resolved:\n{dropin}"
        );

        a.user = true;
        a.data_root = None;
        let dropin = hardening_dropin(&a);
        assert!(
            !dropin.contains("ProtectHome"),
            "a --user unit's data root IS the caller's home, and ProtectHome= hides \
             it:\n{dropin}"
        );
        assert!(
            dropin.contains("CapabilityBoundingSet=\n"),
            "dropping ProtectHome must not drop the rest of the block:\n{dropin}"
        );
        assert!(
            dropin.contains(&format!(
                "ReadWritePaths={}",
                crate::cli::default_data_root().display()
            )),
            "a --user unit must be able to write the root it was told to use:\n{dropin}"
        );
    }

    /// The drop-in has to be somewhere systemd reads it — beside the unit, in
    /// the same directory Skidbladnir writes the unit into, with the `.d`
    /// suffix systemd looks for. A correct file in the wrong place hardens
    /// nothing and looks exactly like a correct one from the CLI's output.
    #[test]
    fn the_dropin_lands_beside_the_unit_systemd_will_read() {
        let a = args();
        let system = dropin_dir(&a).expect("a system-scope drop-in needs no environment");
        assert_eq!(
            system,
            PathBuf::from("/etc/systemd/system/gunnar.service.d"),
            "systemd reads <unit>.d/ next to the unit, and Skidbladnir writes the \
             system unit into /etc/systemd/system"
        );

        let mut user = a;
        user.user = true;
        let dir = dropin_dir(&user).expect("$HOME is set in the test environment");
        assert!(
            dir.ends_with(".config/systemd/user/gunnar.service.d"),
            "a --user unit's drop-in must sit beside the --user unit, not in /etc: {}",
            dir.display()
        );
    }

    /// An explicit `--data-root` outranks both defaults, in both scopes. Without
    /// this, [`resolved_data_root`] could ignore the flag entirely and every
    /// other test here would still pass.
    #[test]
    fn an_explicit_data_root_wins_in_either_scope() {
        let mut a = args();
        a.data_root = Some(PathBuf::from("/srv/gunnar"));
        assert_eq!(resolved_data_root(&a), PathBuf::from("/srv/gunnar"));
        a.user = true;
        assert_eq!(resolved_data_root(&a), PathBuf::from("/srv/gunnar"));
    }
}