Skip to main content

running_process/boot_autostart/
mod.rs

1//! Per-OS boot autostart for the `runpm` daemon (Phase 4 of #222 — #427).
2//!
3//! Each OS implementation exposes the same trio:
4//!   - `install(daemon_binary)` — write the unit/plist/task and arm the
5//!     init system. Returns the unit path that was written.
6//!   - `uninstall()` — disarm the init system and remove the unit.
7//!   - `render_unit(daemon_binary)` — render the unit text without
8//!     touching the filesystem. Used by fixture tests and by `install`.
9//!
10//! Backends:
11//!   - Linux: systemd user unit at
12//!     `$XDG_CONFIG_HOME/systemd/user/runpm-daemon.service`.
13//!   - macOS: launchd user agent at
14//!     `~/Library/LaunchAgents/com.zackees.runpm-daemon.plist`.
15//!   - Windows: Task Scheduler ONLOGON task named `runpm-daemon` via
16//!     the `schtasks` CLI.
17//!
18//! Tests never call `install` — they assert against `render_unit` output
19//! to avoid mutating the runner's init system.
20
21use std::fmt;
22use std::path::{Path, PathBuf};
23
24#[cfg(target_os = "linux")]
25pub mod linux;
26#[cfg(target_os = "macos")]
27pub mod macos;
28#[cfg(target_os = "windows")]
29pub mod windows;
30
31/// Typed wrapper around the path where the unit/plist/task was written.
32/// Wrapped so callers can't accidentally pass it as a generic `PathBuf`
33/// and lose the "this is the autostart artifact" intent.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct UnitPath(pub PathBuf);
36
37impl UnitPath {
38    pub fn as_path(&self) -> &Path {
39        &self.0
40    }
41
42    pub fn into_inner(self) -> PathBuf {
43        self.0
44    }
45}
46
47impl fmt::Display for UnitPath {
48    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
49        write!(f, "{}", self.0.display())
50    }
51}
52
53/// Anything that can go wrong installing/uninstalling boot autostart.
54#[derive(Debug)]
55pub enum BootAutostartError {
56    /// Could not resolve where to write the unit file.
57    Resolve(String),
58    /// Filesystem write/remove failed.
59    Io(std::io::Error),
60    /// The init-system CLI (`systemctl`, `launchctl`, `schtasks`) failed.
61    /// Non-zero exit code does NOT propagate as `Io`; it lands here so
62    /// callers can distinguish "we wrote the file but enabling failed"
63    /// from "filesystem error".
64    InitSystem(String),
65    /// Compiled for an OS we do not have a backend for (defensive — the
66    /// public install/uninstall functions are `cfg`-gated, so this only
67    /// fires if someone bypasses the gate).
68    Unsupported(String),
69}
70
71impl fmt::Display for BootAutostartError {
72    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
73        match self {
74            BootAutostartError::Resolve(m) => write!(f, "could not resolve install path: {m}"),
75            BootAutostartError::Io(e) => write!(f, "filesystem error: {e}"),
76            BootAutostartError::InitSystem(m) => write!(f, "init system error: {m}"),
77            BootAutostartError::Unsupported(os) => {
78                write!(f, "boot autostart not supported on {os}")
79            }
80        }
81    }
82}
83
84impl std::error::Error for BootAutostartError {}
85
86impl From<std::io::Error> for BootAutostartError {
87    fn from(e: std::io::Error) -> Self {
88        BootAutostartError::Io(e)
89    }
90}
91
92// ---------------------------------------------------------------------------
93// Public entry points (cfg-dispatched)
94// ---------------------------------------------------------------------------
95
96/// Install boot autostart for the running-process daemon. Returns the
97/// path where the unit/plist/task was written.
98pub fn install(daemon_binary: &Path) -> Result<UnitPath, BootAutostartError> {
99    #[cfg(target_os = "linux")]
100    {
101        linux::install(daemon_binary)
102    }
103    #[cfg(target_os = "macos")]
104    {
105        macos::install(daemon_binary)
106    }
107    #[cfg(target_os = "windows")]
108    {
109        windows::install(daemon_binary)
110    }
111    #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
112    {
113        let _ = daemon_binary;
114        Err(BootAutostartError::Unsupported(
115            std::env::consts::OS.to_string(),
116        ))
117    }
118}
119
120/// Uninstall boot autostart for the running-process daemon.
121pub fn uninstall() -> Result<(), BootAutostartError> {
122    #[cfg(target_os = "linux")]
123    {
124        linux::uninstall()
125    }
126    #[cfg(target_os = "macos")]
127    {
128        macos::uninstall()
129    }
130    #[cfg(target_os = "windows")]
131    {
132        windows::uninstall()
133    }
134    #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
135    {
136        Err(BootAutostartError::Unsupported(
137            std::env::consts::OS.to_string(),
138        ))
139    }
140}
141
142/// Render the unit/plist/task text for the current OS without touching
143/// the filesystem. Test seam used by `tests/runpm_boot_autostart_fixtures.rs`.
144pub fn render_unit(daemon_binary: &Path) -> String {
145    #[cfg(target_os = "linux")]
146    {
147        linux::render_unit(daemon_binary)
148    }
149    #[cfg(target_os = "macos")]
150    {
151        macos::render_unit(daemon_binary)
152    }
153    #[cfg(target_os = "windows")]
154    {
155        windows::render_unit(daemon_binary)
156    }
157    #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
158    {
159        let _ = daemon_binary;
160        String::new()
161    }
162}
163
164// ---------------------------------------------------------------------------
165// Shared shell-quoting helper (Linux + macOS).
166// ---------------------------------------------------------------------------
167
168/// Wrap a string in POSIX single-quotes, escaping embedded single quotes
169/// with the standard `'\''` dance. Safe for paths-with-spaces injected
170/// into the systemd unit's `ExecStart` line or the macOS plist's
171/// `ProgramArguments` array.
172#[cfg(any(target_os = "linux", test))]
173pub(crate) fn shell_quote_single(s: &str) -> String {
174    let mut out = String::with_capacity(s.len() + 2);
175    out.push('\'');
176    for ch in s.chars() {
177        if ch == '\'' {
178            // Close quote, escaped literal single, re-open quote.
179            out.push_str("'\\''");
180        } else {
181            out.push(ch);
182        }
183    }
184    out.push('\'');
185    out
186}
187
188/// Escape a string for inclusion inside an XML/plist text node. Only
189/// `<`, `>`, `&`, `"`, and `'` need escaping for plist values.
190#[cfg(any(target_os = "macos", test))]
191pub(crate) fn xml_escape(s: &str) -> String {
192    let mut out = String::with_capacity(s.len());
193    for ch in s.chars() {
194        match ch {
195            '<' => out.push_str("&lt;"),
196            '>' => out.push_str("&gt;"),
197            '&' => out.push_str("&amp;"),
198            '"' => out.push_str("&quot;"),
199            '\'' => out.push_str("&apos;"),
200            other => out.push(other),
201        }
202    }
203    out
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    #[test]
211    fn shell_quote_wraps_simple_path() {
212        assert_eq!(shell_quote_single("/usr/bin/foo"), "'/usr/bin/foo'");
213    }
214
215    #[test]
216    fn shell_quote_escapes_embedded_single_quote() {
217        assert_eq!(shell_quote_single("o'malley"), "'o'\\''malley'");
218    }
219
220    #[test]
221    fn xml_escape_handles_metacharacters() {
222        assert_eq!(
223            xml_escape("a<b&c>d\"e'f"),
224            "a&lt;b&amp;c&gt;d&quot;e&apos;f"
225        );
226    }
227}