From fc66dbfbcc7d25862375addf0bfa8799b9c1fcdb Mon Sep 17 00:00:00 2001 From: mrigbozurike Date: Mon, 5 Oct 2026 08:18:47 +0100 Subject: [PATCH] systemd: sandbox the user unit Signed-off-by: mrigbozurike --- CHANGELOG.md | 14 ++ docs/setup-linux.md | 63 ++++++ src/service/systemd.rs | 491 +++++++++++++++++++++++++++++++++++++++-- 3 files changed, 554 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1891556..d4fa644 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ builds the GitHub release body from the matching `## ` section. ## Unreleased +### Added +- **The systemd `--user` unit is sandboxed.** `rdc service install` writes `NoNewPrivileges`, + a `@system-service` syscall filter minus `@privileged`/`@resources`, `RestrictAddressFamilies`, + `RestrictNamespaces`/`RestrictRealtime`/`RestrictSUIDSGID`/`LockPersonality` and `UMask=0077` + into the unit, and the directives that need user namespaces (`PrivateUsers`, an empty + capability set, the `Protect*` family, `ProtectSystem=strict`, `ProtectHome=read-only`, + `PrivateTmp` with the X11 socket bound back) into a drop-in written only when a real + `systemd-run --user` probe starts a transient unit carrying them, since those would otherwise + stop the unit from starting at all. Paths are quoted and `%` escaped, so a binary or audit + directory containing a space or a percent sign is written correctly. Installing restarts a + running daemon rather than leaving the old one in place, and the unit is `Type=exec`, so + install can wait past `RestartSec` and confirm `NRestarts` is 0 instead of mistaking a restart + loop for success. + ## 0.4.0 — 2026-09-11 ### Added diff --git a/docs/setup-linux.md b/docs/setup-linux.md index f5ac738..49baeac 100644 --- a/docs/setup-linux.md +++ b/docs/setup-linux.md @@ -56,6 +56,69 @@ restarts on failure. It runs the binary from the path where you invoked `service Config lives at `~/.config/rdc/config.toml`; see [Configuration](configuration.md). +### What the unit is allowed to do + +`rdc service install` writes a sandboxed unit. The daemon runs as your user inside your session +(it has to, to see the screen and inject input), but systemd fences off everything it does not +need. The directives are split across two files because in a `--user` service they are not all +equally available: + +**Always applied**, in `dev.rdc.daemon.service` — these are pure seccomp and prctl settings and +need no namespace: + +| Restriction | Effect | +|---|---| +| `NoNewPrivileges`, `RestrictSUIDSGID` | neither rdc nor anything it runs (`hyprctl`, `tailscale`) can gain privileges | +| `SystemCallFilter=@system-service` minus `@privileged @resources`, native ABI only | privileged and resource-limit syscalls return `EPERM` | +| `RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6` | unix sockets (Wayland, D-Bus, portal, tailscaled) and the HTTP listener; nothing else | +| `RestrictNamespaces`, `RestrictRealtime`, `LockPersonality` | no new namespaces, no realtime scheduling, no personality changes | +| `UMask=0077` | audit log and rotated copies are private to your user | + +**Applied when unprivileged user namespaces work**, in +`dev.rdc.daemon.service.d/10-sandbox.conf`: + +| Restriction | Effect | +|---|---| +| `PrivateUsers=yes` | every uid but yours maps to `nobody` inside the service | +| empty `CapabilityBoundingSet`/`AmbientCapabilities` | rdc can never hold a capability | +| `ProtectKernelTunables`, `ProtectKernelModules`, `ProtectKernelLogs`, `ProtectControlGroups`, `ProtectClock`, `ProtectHostname` | no kernel tunables, modules, logs, cgroups, clock or hostname | +| `ProtectSystem=strict`, `ProtectHome=read-only`, `PrivateTmp` | the filesystem is read-only except `$XDG_RUNTIME_DIR`, `~/.local/state/rdc` and the audit log directory | +| `BindReadOnlyPaths="-/tmp/.X11-unix"` | puts the X11 socket back, which `PrivateTmp` would otherwise hide | + +Every directive in the second table implies `PrivateUsers=` in a `--user` service (see +`systemd.exec(5)`), so where unprivileged user namespaces are unavailable the unit would refuse +to start at all. `rdc service install` therefore asks systemd itself rather than guessing: it +runs a throwaway transient unit, + +```sh +systemd-run --user --wait --quiet -p PrivateUsers=yes -p ProtectSystem=strict -p PrivateTmp=yes /bin/true +``` + +and writes the drop-in only if that starts. Reading `/proc/sys` is not enough (Ubuntu 23.10+ +restricts namespaces through an AppArmor profile the old knobs do not reflect) and neither is +`unshare -Ur`, which can succeed on hosts where systemd's own mount setup still fails. Install +says so when it skips the drop-in, and removes a stale one if an upgrade takes namespaces away. + +Paths in the generated files are quoted and any `%` is written `%%`, so a binary or audit +directory containing a space is one path rather than two, and a `%` is not read as a systemd +specifier. A path containing a quote, a backslash or a newline is refused at install time +rather than written out ambiguously. + +Installing over a running daemon **restarts** it, so the new settings take effect immediately +instead of at the next login. The unit is `Type=exec`, which means systemd calls it active only +once the binary has actually been reached; install then waits past `RestartSec` and checks that +`NRestarts` is still 0, so a daemon that starts and dies in a loop is reported rather than +mistaken for a healthy one. + +`RemoveIPC` is deliberately not set: in a `--user` unit it would delete every shared-memory +object owned by your account when the unit stops, which can take Xwayland (MIT-SHM) and +PipeWire down with it. + +Check the result with `systemd-analyze security --user dev.rdc.daemon`. If your desktop needs +something the sandbox blocks (the journal will show `EPERM` or a mount error), override it with +`systemctl --user edit dev.rdc.daemon` rather than editing the generated files, so the change +survives the next `rdc service install`. + ## Tailscale rdc uses the LocalAPI socket at `/var/run/tailscale/tailscaled.sock`. If your user can't read it, diff --git a/src/service/systemd.rs b/src/service/systemd.rs index 065da6c..d353b91 100644 --- a/src/service/systemd.rs +++ b/src/service/systemd.rs @@ -1,39 +1,349 @@ +//! Linux: a systemd --user unit that runs `rdc serve` inside the graphical session. +//! +//! The unit is sandboxed. The daemon needs very little from the machine beyond the desktop +//! session it is controlling: read access to its config file, write access to its state +//! directory (audit log), the session's runtime directory (Wayland, D-Bus, portal sockets), +//! the tailscaled socket, and one TCP listener. Everything else is fenced off so a bug in rdc +//! or in one of its capture/input libraries cannot become a foothold on the rest of the +//! account or the system. +//! +//! The directives are split across two files, and the split is not cosmetic. In a `--user` +//! service, everything that needs a private mount or user namespace — `ProtectSystem`, +//! `ProtectHome`, `PrivateTmp`, and also `ProtectKernelTunables`, `ProtectKernelModules`, +//! `ProtectKernelLogs`, `ProtectControlGroups`, `ProtectClock`, `ProtectHostname` and +//! `CapabilityBoundingSet`, which imply `PrivateUsers=` (see systemd.exec(5)) — fails to start +//! at all when unprivileged user namespaces are unavailable. Those live in a drop-in that is +//! written only when a real probe says namespaces work. The base unit keeps the directives +//! that are pure seccomp/prctl and always apply. + use super::{LABEL, Op, service_binary}; use anyhow::{Context, Result}; +use std::path::{Path, PathBuf}; use std::process::Command; -fn unit_path() -> Result { - Ok(dirs::config_dir().context("no config dir")?.join("systemd/user").join(format!("{LABEL}.service"))) +fn unit_dir() -> Result { + Ok(dirs::config_dir().context("no config dir")?.join("systemd/user")) +} + +fn unit_name() -> String { + format!("{LABEL}.service") +} + +/// `/dev.rdc.daemon.service.d/10-sandbox.conf`, relative to a unit directory. +fn dropin_path(dir: &Path) -> PathBuf { + dir.join(format!("{LABEL}.service.d")).join("10-sandbox.conf") } fn systemctl(args: &[&str]) -> Result { Ok(Command::new("systemctl").arg("--user").args(args).output()?) } +/// `systemctl --user show -p --value `, trimmed. +fn show(name: &str, property: &str) -> Result { + let out = systemctl(&["show", "-p", property, "--value", name])?; + Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) +} + +/// `RestartSec` in the unit below. The start check has to outlast it to see a restart loop. +const RESTART_SEC: u64 = 3; + +/// Render a path as one unit-file token. +/// +/// systemd splits these values on whitespace, so an unquoted `/var/log/my logs` becomes two +/// paths; and it reads `%` as the start of a specifier, so `100%` would be rewritten or +/// rejected. Double quotes group the token, and `%%` is the escape for a literal `%`. +/// [`check_unit_path`] has already refused the characters this cannot express. +fn unit_quote(p: &Path) -> String { + format!("\"{}\"", p.to_string_lossy().replace('%', "%%")) +} + +/// Refuse paths a unit file cannot carry unambiguously. A quote would end the token early; a +/// backslash is unescaped in `ExecStart=` but retained in `ReadWritePaths=`, so the same text +/// would mean two different paths; a newline would end the line. +fn check_unit_path(p: &Path, what: &str) -> Result<()> { + let s = p.to_string_lossy(); + if let Some(bad) = s.chars().find(|c| matches!(c, '"' | '\\' | '\n' | '\r')) { + anyhow::bail!( + "the {what} path {} contains {bad:?}, which a systemd unit file cannot carry unambiguously; \ + move it somewhere without that character, or point [serve.audit].path elsewhere", + p.display() + ); + } + Ok(()) +} + +/// The base unit: only directives that need no namespace of any kind, so this file is always +/// written and always starts. +pub(crate) fn unit_text(bin: &Path) -> String { + format!( + "[Unit]\n\ + Description=rdc remote desktop control daemon\n\ + Documentation=https://github.com/bscott/rdc\n\ + After=graphical-session.target tailscaled.service\n\ + PartOf=graphical-session.target\n\ + \n\ + [Service]\n\ + # exec, not simple: `active` then means the binary was reached, so a daemon that dies\n\ + # on startup is visible as a failure instead of a unit that briefly looked fine.\n\ + Type=exec\n\ + ExecStart={bin} serve\n\ + Restart=on-failure\n\ + RestartSec={restart_sec}\n\ + Environment=RDC_LOG=info\n\ + \n\ + # --- least privilege (seccomp and prctl only; see the drop-in for the rest) -------\n\ + # Nothing rdc execs (hyprctl, tailscale) may gain privileges.\n\ + NoNewPrivileges=yes\n\ + # Files it creates (audit log, rotated copies) are private to your user.\n\ + UMask=0077\n\ + # Sockets: Wayland/D-Bus/portal/tailscaled (unix) and the HTTP listener (inet).\n\ + RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6\n\ + # No new namespaces, no realtime scheduling, no personality changes, native ABI only.\n\ + RestrictNamespaces=yes\n\ + RestrictRealtime=yes\n\ + RestrictSUIDSGID=yes\n\ + LockPersonality=yes\n\ + SystemCallArchitectures=native\n\ + # The standard service allow-list, minus privileged ops and resource-limit tweaks.\n\ + SystemCallFilter=@system-service\n\ + SystemCallFilter=~@privileged @resources\n\ + SystemCallErrorNumber=EPERM\n\ + # No IPC removal on stop: in a --user unit that would delete every SysV/POSIX IPC\n\ + # object the desktop user owns and can break the session (Xwayland MIT-SHM, PipeWire).\n\ + \n\ + [Install]\n\ + WantedBy=graphical-session.target\n", + bin = unit_quote(bin), + restart_sec = RESTART_SEC, + ) +} + +/// Everything that needs user or mount namespaces. Written only when [`userns_available`] +/// confirms the kernel allows them, because without them systemd refuses to start the unit. +pub(crate) fn dropin_text(rw: &[PathBuf]) -> String { + let mut s = String::from( + "# Namespace sandbox for rdc, written by `rdc service install` only when unprivileged\n\ + # user namespaces work on this kernel. Every directive here needs them: the Protect*\n\ + # ones below and CapabilityBoundingSet imply PrivateUsers= in a --user service.\n\ + #\n\ + # If your desktop needs something this blocks, override it with\n\ + # `systemctl --user edit dev.rdc.daemon` rather than editing this file, so the change\n\ + # survives the next `rdc service install`.\n\ + [Service]\n\ + PrivateUsers=yes\n\ + # rdc never holds capabilities; say so explicitly.\n\ + CapabilityBoundingSet=\n\ + AmbientCapabilities=\n\ + # Kernel and host surfaces the daemon has no business touching.\n\ + ProtectKernelTunables=yes\n\ + ProtectKernelModules=yes\n\ + ProtectKernelLogs=yes\n\ + ProtectControlGroups=yes\n\ + ProtectClock=yes\n\ + ProtectHostname=yes\n\ + # / is read-only, /home and /root are read-only, /tmp and /dev/shm are private.\n\ + ProtectSystem=strict\n\ + ProtectHome=read-only\n\ + PrivateTmp=yes\n\ + # PrivateTmp hides the X11 socket directory, which an X11 session needs to reach the\n\ + # display. A leading '-' skips it on Wayland-only systems where it does not exist.\n\ + BindReadOnlyPaths=\"-/tmp/.X11-unix\"\n\ + # Writable: the session runtime dir (%t) and rdc's own state. Paths are quoted so a\n\ + # space does not split one entry into two; %% is a literal %.\n\ + ReadWritePaths=\"-%t\"\n", + ); + for p in rw { + s.push_str(&format!("ReadWritePaths=\"-{}\"\n", p.to_string_lossy().replace('%', "%%"))); + } + s +} + +/// Will the user manager actually be able to start a unit with the sandbox directives? +/// +/// The only answer that counts comes from systemd itself. Asking the kernel is not enough: +/// `unshare -Ur` can succeed on a host where systemd's own mount setup still fails (Ubuntu +/// 24.04 does exactly this), and reading `/proc/sys` misses the AppArmor profile that Ubuntu +/// 23.10+ uses to restrict unprivileged user namespaces. So run a throwaway transient unit +/// with the directives that matter and see whether it starts. +/// +/// Falls back to the kernel knobs only if `systemd-run` is missing, which should not happen on +/// a machine where `systemctl --user` works. +fn sandbox_supported() -> bool { + let probe = Command::new("systemd-run") + .args([ + "--user", + "--wait", + "--quiet", + "-p", + "PrivateUsers=yes", + "-p", + "ProtectSystem=strict", + "-p", + "PrivateTmp=yes", + "/bin/true", + ]) + .output(); + match probe { + Ok(out) => out.status.success(), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => sysctls_allow_userns(), + Err(_) => false, + } +} + +fn sysctls_allow_userns() -> bool { + for knob in ["/proc/sys/kernel/unprivileged_userns_clone", "/proc/sys/user/max_user_namespaces"] { + if let Ok(v) = std::fs::read_to_string(knob) + && v.trim() == "0" + { + return false; + } + } + true +} + +/// Directories the daemon must be able to write: its state dir and, if the audit log was +/// pointed somewhere else in config, that directory too. +fn writable_dirs() -> Vec { + let mut dirs = vec![crate::config::state_dir()]; + if let Ok(cfg) = crate::config::load() + && let Some(parent) = cfg.serve.audit.resolved_path().parent() + && !dirs.iter().any(|d| d == parent) + { + dirs.push(parent.to_path_buf()); + } + dirs +} + +/// Write the unit, and the drop-in when `sandbox` is true (removing a stale one when it is +/// not). Returns the paths written. Split out from [`run`] so tests can drive it on a temp +/// directory without touching systemd. +pub(crate) fn write_unit_files(dir: &Path, bin: &Path, rw: &[PathBuf], sandbox: bool) -> Result> { + std::fs::create_dir_all(dir)?; + let unit = dir.join(unit_name()); + std::fs::write(&unit, unit_text(bin)).with_context(|| format!("writing {}", unit.display()))?; + let mut written = vec![unit]; + let dropin = dropin_path(dir); + if sandbox { + std::fs::create_dir_all(dropin.parent().unwrap())?; + std::fs::write(&dropin, dropin_text(rw)).with_context(|| format!("writing {}", dropin.display()))?; + written.push(dropin); + } else { + // A kernel that used to allow namespaces may not any more (a distribution upgrade + // tightening AppArmor, say). Leaving the old drop-in would wedge the unit. + remove_unit_files(dir, false)?; + } + Ok(written) +} + +/// Remove the drop-in (and its directory), and the unit itself when `unit_too`. Returns what +/// was actually removed. +pub(crate) fn remove_unit_files(dir: &Path, unit_too: bool) -> Result> { + let mut removed = Vec::new(); + let dropin = dropin_path(dir); + if dropin.exists() { + std::fs::remove_file(&dropin).with_context(|| format!("removing {}", dropin.display()))?; + removed.push(dropin.clone()); + } + if let Some(d) = dropin.parent() + && d.exists() + { + // Only if empty: a user may have added their own drop-in beside ours. + if std::fs::read_dir(d)?.next().is_none() { + std::fs::remove_dir(d)?; + removed.push(d.to_path_buf()); + } + } + if unit_too { + let unit = dir.join(unit_name()); + if unit.exists() { + std::fs::remove_file(&unit).with_context(|| format!("removing {}", unit.display()))?; + removed.push(unit); + } + } + Ok(removed) +} + +/// Did the daemon actually stay up? +/// +/// `Restart=on-failure` means a daemon that dies on startup is restarted rather than left +/// failed, so it can report `activating` or even `active` again by the time anyone looks. The +/// only reliable signal is to outlast `RestartSec` and then insist that it has never been +/// restarted: `NRestarts` is still 0. +fn confirm_running(name: &str) -> Result<(String, String)> { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(RESTART_SEC + 1); + let mut state = show(name, "ActiveState")?; + while std::time::Instant::now() < deadline { + if state == "failed" { + break; + } + std::thread::sleep(std::time::Duration::from_millis(200)); + state = show(name, "ActiveState")?; + } + let restarts = show(name, "NRestarts")?; + Ok((state, restarts)) +} + pub fn run(op: Op) -> Result<()> { - let unit = unit_path()?; - let name = format!("{LABEL}.service"); + let dir = unit_dir()?; + let name = unit_name(); match op { Op::Install { .. } => { let bin = service_binary()?; - std::fs::create_dir_all(unit.parent().unwrap())?; - let text = format!( - "[Unit]\nDescription=rdc remote desktop control daemon\nAfter=graphical-session.target tailscaled.service\nPartOf=graphical-session.target\n\n[Service]\nExecStart={} serve\nRestart=on-failure\nRestartSec=3\nEnvironment=RDC_LOG=info\n\n[Install]\nWantedBy=graphical-session.target\n", - bin.display() - ); - std::fs::write(&unit, text).with_context(|| format!("writing {}", unit.display()))?; + check_unit_path(&bin, "rdc binary")?; + let rw = writable_dirs(); + for d in &rw { + check_unit_path(d, "writable directory")?; + } + for d in &rw { + // Create them now so ReadWritePaths has something to bind and the first audit + // write does not have to mkdir through a read-only home. + let _ = std::fs::create_dir_all(d); + } + let sandbox = sandbox_supported(); + let written = write_unit_files(&dir, &bin, &rw, sandbox)?; + systemctl(&["daemon-reload"])?; - let out = systemctl(&["enable", "--now", &name])?; + let out = systemctl(&["enable", &name])?; if !out.status.success() { anyhow::bail!("systemctl enable failed: {}", String::from_utf8_lossy(&out.stderr).trim()); } - println!("installed {} → {}\nlogs: journalctl --user -u {name} -f", unit.display(), bin.display()); + // `restart`, not `start`: an install over a running daemon has to replace it, or the + // old process keeps serving with the old (or no) sandbox until the next reboot. + let out = systemctl(&["restart", &name])?; + if !out.status.success() { + anyhow::bail!("systemctl restart failed: {}", String::from_utf8_lossy(&out.stderr).trim()); + } + for p in &written { + println!("wrote {}", p.display()); + } + if !sandbox { + println!( + "namespace sandbox: skipped — this kernel does not allow unprivileged user namespaces\n\ + (Ubuntu 23.10+ restricts them via AppArmor). The syscall, capability-escalation and\n\ + address-family restrictions in the unit still apply." + ); + } + let (state, restarts) = confirm_running(&name)?; + if state != "active" || restarts != "0" { + let status = systemctl(&["status", "--no-pager", "--lines=15", &name])?; + anyhow::bail!( + "{name} did not stay running (ActiveState={state:?}, NRestarts={restarts}). The sandbox may \ + be blocking something this desktop needs; try `systemctl --user edit {name}` to override a \ + directive, or delete {}.\n\n{}", + dropin_path(&dir).display(), + String::from_utf8_lossy(&status.stdout).trim() + ); + } + println!( + "{name} is active, running {}\nlogs: journalctl --user -u {name} -f\nsandbox score: systemd-analyze security --user {name}", + bin.display() + ); Ok(()) } Op::Uninstall => { let _ = systemctl(&["disable", "--now", &name]); - if unit.exists() { - std::fs::remove_file(&unit)?; + for p in remove_unit_files(&dir, true)? { + println!("removed {}", p.display()); } systemctl(&["daemon-reload"])?; println!("removed {name}"); @@ -46,3 +356,156 @@ pub fn run(op: Op) -> Result<()> { } } } + +#[cfg(test)] +mod tests { + use super::*; + + fn tmp(name: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("rdc-systemd-test-{}-{name}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + d + } + + /// Directives that must never move into the base unit: each one implies `PrivateUsers=` in + /// a `--user` service, so a kernel without namespaces would refuse to start the unit. + const NEEDS_NAMESPACES: &[&str] = &[ + "PrivateUsers=", + "CapabilityBoundingSet=", + "AmbientCapabilities=", + "ProtectKernelTunables=", + "ProtectKernelModules=", + "ProtectKernelLogs=", + "ProtectControlGroups=", + "ProtectClock=", + "ProtectHostname=", + "ProtectSystem=", + "ProtectHome=", + "PrivateTmp=", + ]; + + #[test] + fn base_unit_starts_without_namespaces() { + let u = unit_text(Path::new("/usr/local/bin/rdc")); + assert!(u.contains("ExecStart=\"/usr/local/bin/rdc\" serve\n")); + assert!(u.contains("Type=exec\n"), "Type=simple would call a dying daemon active"); + for d in ["NoNewPrivileges=yes", "SystemCallFilter=@system-service", "RestrictAddressFamilies=", "UMask=0077"] { + assert!(u.contains(d), "base unit lost {d}"); + } + for d in NEEDS_NAMESPACES { + assert!(!u.contains(d), "{d} needs user namespaces and must live in the drop-in"); + } + // Would tear down the desktop user's shared memory (Xwayland, PipeWire) on stop. + assert!(!u.contains("RemoveIPC=yes")); + } + + #[test] + fn dropin_carries_the_namespace_directives_and_the_x11_socket() { + let d = dropin_text(&[PathBuf::from("/home/a/.local/state/rdc")]); + for x in NEEDS_NAMESPACES { + assert!(d.contains(x), "drop-in missing {x}"); + } + // Without this, PrivateTmp hides the X11 socket and an X11 session cannot be reached. + assert!(d.contains("BindReadOnlyPaths=\"-/tmp/.X11-unix\"")); + assert!(d.contains("ReadWritePaths=\"-%t\"\n")); + assert!(d.contains("ReadWritePaths=\"-/home/a/.local/state/rdc\"\n")); + } + + #[test] + fn dropin_makes_every_writable_dir_writable_and_nothing_else() { + // State dir plus a custom [serve.audit].path directory, as writable_dirs() returns them. + let rw = [PathBuf::from("/home/a/.local/state/rdc"), PathBuf::from("/var/log/rdc")]; + let d = dropin_text(&rw); + let writable: Vec<&str> = d.lines().filter_map(|l| l.strip_prefix("ReadWritePaths=")).collect(); + assert_eq!(writable, ["\"-%t\"", "\"-/home/a/.local/state/rdc\"", "\"-/var/log/rdc\""]); + // Everything else stays read-only. + assert!(d.contains("ProtectSystem=strict\n") && d.contains("ProtectHome=read-only\n")); + } + + #[test] + fn reinstall_rewrites_the_same_files() { + let dir = tmp("reinstall"); + let rw = [PathBuf::from("/home/a/.local/state/rdc")]; + let first = write_unit_files(&dir, Path::new("/usr/local/bin/rdc"), &rw, true).unwrap(); + let before: Vec = first.iter().map(|p| std::fs::read_to_string(p).unwrap()).collect(); + let second = write_unit_files(&dir, Path::new("/usr/local/bin/rdc"), &rw, true).unwrap(); + assert_eq!(first, second); + let after: Vec = second.iter().map(|p| std::fs::read_to_string(p).unwrap()).collect(); + assert_eq!(before, after, "a second install must not change or duplicate directives"); + // Nothing is written outside the unit directory. + assert!(second.iter().all(|p| p.starts_with(&dir)), "{second:?}"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn paths_are_quoted_and_percent_is_escaped() { + // Unquoted, this ExecStart would be the command `/opt/my` with the argument `rdc/rdc`. + let u = unit_text(Path::new("/opt/my rdc/rdc")); + assert!(u.contains("ExecStart=\"/opt/my rdc/rdc\" serve\n"), "{u}"); + // Unquoted, this would be two paths, and `% d` would be read as a specifier. + let d = dropin_text(&[PathBuf::from("/var/log/100% done/rdc")]); + assert!(d.contains("ReadWritePaths=\"-/var/log/100%% done/rdc\"\n"), "{d}"); + // %t is a specifier we write ourselves and must stay expandable. + assert!(d.contains("ReadWritePaths=\"-%t\"\n"), "{d}"); + } + + #[test] + fn paths_a_unit_file_cannot_express_are_refused() { + assert!(check_unit_path(Path::new("/opt/rdc/rdc"), "rdc binary").is_ok()); + assert!(check_unit_path(Path::new("/var/log/100% done"), "writable directory").is_ok()); + for bad in ["/opt/r\"dc", "/opt/r\\dc", "/opt/r\ndc"] { + let e = check_unit_path(Path::new(bad), "rdc binary").unwrap_err().to_string(); + assert!(e.contains("cannot carry unambiguously"), "{bad:?} was accepted: {e}"); + } + } + + #[test] + fn install_without_namespaces_writes_no_dropin() { + let dir = tmp("no-userns"); + let written = write_unit_files(&dir, Path::new("/usr/local/bin/rdc"), &[], false).unwrap(); + assert_eq!(written.len(), 1, "only the unit should be written"); + assert!(dir.join(unit_name()).exists()); + assert!(!dropin_path(&dir).exists()); + // The unit alone must still be a valid, startable unit. + let u = std::fs::read_to_string(dir.join(unit_name())).unwrap(); + assert!(u.contains("NoNewPrivileges=yes") && u.contains("[Install]")); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn install_removes_a_stale_dropin_when_namespaces_go_away() { + let dir = tmp("stale"); + write_unit_files(&dir, Path::new("/usr/local/bin/rdc"), &[], true).unwrap(); + assert!(dropin_path(&dir).exists()); + // Same machine, kernel tightened since the last install. + write_unit_files(&dir, Path::new("/usr/local/bin/rdc"), &[], false).unwrap(); + assert!(!dropin_path(&dir).exists(), "a stale drop-in would wedge the unit"); + assert!(!dropin_path(&dir).parent().unwrap().exists()); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn uninstall_removes_the_unit_and_the_dropin() { + let dir = tmp("uninstall"); + write_unit_files(&dir, Path::new("/usr/local/bin/rdc"), &[], true).unwrap(); + let removed = remove_unit_files(&dir, true).unwrap(); + assert!(!dir.join(unit_name()).exists(), "unit left behind"); + assert!(!dropin_path(&dir).exists(), "drop-in left behind"); + assert!(!dropin_path(&dir).parent().unwrap().exists(), "drop-in directory left behind"); + assert_eq!(removed.len(), 3); + // Removing twice is not an error: `service uninstall` may be run after a manual cleanup. + assert!(remove_unit_files(&dir, true).unwrap().is_empty()); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn uninstall_keeps_a_dropin_directory_that_has_other_files() { + let dir = tmp("other-dropin"); + write_unit_files(&dir, Path::new("/usr/local/bin/rdc"), &[], true).unwrap(); + let theirs = dropin_path(&dir).parent().unwrap().join("99-local.conf"); + std::fs::write(&theirs, "[Service]\nEnvironment=RDC_LOG=debug\n").unwrap(); + remove_unit_files(&dir, true).unwrap(); + assert!(theirs.exists(), "someone else's override must not be deleted"); + let _ = std::fs::remove_dir_all(&dir); + } +}