1use std::os::unix::io::{FromRawFd, RawFd};
20use std::os::unix::net::UnixListener as StdUnixListener;
21use std::path::{Path, PathBuf};
22use std::process::Command;
23use std::time::Duration;
24
25use anyhow::{bail, Context, Result};
26use tokio::net::UnixListener;
27
28use super::{paths, ServiceSelection};
29
30pub const SERVICE_UNIT: &str = "omni-dev-daemon.service";
32
33pub const SOCKET_UNIT: &str = "omni-dev-daemon.socket";
35
36const DISABLE_ENV: &str = "OMNI_DEV_DAEMON_DISABLE_SYSTEMD";
41
42fn unit_dir() -> Result<PathBuf> {
45 let base = dirs::config_dir().context("could not determine the user config directory")?;
46 Ok(base.join("systemd").join("user"))
47}
48
49fn systemd_disabled() -> bool {
51 std::env::var(DISABLE_ENV).is_ok_and(|v| flag_is_truthy(&v))
52}
53
54fn flag_is_truthy(v: &str) -> bool {
57 matches!(
58 v.trim().to_ascii_lowercase().as_str(),
59 "1" | "true" | "yes" | "on"
60 )
61}
62
63pub fn is_available() -> bool {
72 if systemd_disabled() {
73 return false;
74 }
75 if !Path::new("/run/systemd/system").is_dir() {
76 return false;
77 }
78 let runtime = std::env::var_os("XDG_RUNTIME_DIR").map_or_else(
79 || PathBuf::from(format!("/run/user/{}", nix::unistd::getuid())),
80 PathBuf::from,
81 );
82 runtime.join("systemd").join("private").exists()
83}
84
85fn validate_path<'a>(path: &'a Path, what: &str) -> Result<&'a str> {
93 let s = path
94 .to_str()
95 .with_context(|| format!("{what} path is not valid UTF-8: {}", path.display()))?;
96 if s.contains(['\n', '\r']) {
97 bail!("{what} path contains a newline, which cannot be placed in a systemd unit: {s}");
98 }
99 Ok(s)
100}
101
102fn exec_arg(s: &str) -> String {
109 let escaped = s
110 .replace('\\', "\\\\") .replace('"', "\\\"") .replace('%', "%%") .replace('$', "$$"); format!("\"{escaped}\"")
115}
116
117fn escape_value(s: &str) -> String {
121 s.replace('%', "%%")
122}
123
124fn render_service(exe: &Path, socket: &Path, services: &ServiceSelection) -> Result<String> {
133 let exe = validate_path(exe, "executable")?;
134 let socket = validate_path(socket, "socket")?;
135 let services_arg = match services.to_csv() {
141 Some(csv) => format!(" --services {}", exec_arg(&csv)),
142 None => String::new(),
143 };
144 Ok(format!(
145 r"[Unit]
146Description=omni-dev daemon
147Requires={socket_unit}
148After={socket_unit}
149
150[Service]
151ExecStart={exe} daemon run --socket {socket}{services_arg}
152",
153 socket_unit = SOCKET_UNIT,
154 exe = exec_arg(exe),
155 socket = exec_arg(socket),
156 ))
157}
158
159fn render_socket(socket: &Path) -> Result<String> {
167 let socket = validate_path(socket, "socket")?;
168 Ok(format!(
169 r"[Unit]
170Description=omni-dev daemon control socket
171
172[Socket]
173ListenStream={socket}
174SocketMode=0600
175
176[Install]
177WantedBy=sockets.target
178",
179 socket = escape_value(socket),
180 ))
181}
182
183fn systemctl(args: &[&str]) -> Command {
185 let mut command = Command::new("systemctl");
186 command.arg("--user").args(args);
187 command
188}
189
190type Systemctl<'a> = dyn Fn(&[&str]) -> std::io::Result<std::process::Output> + 'a;
195
196fn spawn_systemctl(args: &[&str]) -> std::io::Result<std::process::Output> {
198 systemctl(args).output()
199}
200
201fn daemon_reload(run: &Systemctl) -> Result<()> {
203 let output =
204 run(&["daemon-reload"]).context("failed to run `systemctl --user daemon-reload`")?;
205 if !output.status.success() {
206 bail!(
207 "`systemctl --user daemon-reload` failed: {}",
208 String::from_utf8_lossy(&output.stderr).trim()
209 );
210 }
211 Ok(())
212}
213
214fn enable_now(run: &Systemctl) -> Result<()> {
218 let mut last_err = String::new();
219 for attempt in 0..5 {
220 if attempt > 0 {
221 std::thread::sleep(Duration::from_millis(200));
222 }
223 let output = run(&["enable", "--now", SOCKET_UNIT])
224 .context("failed to run `systemctl --user enable --now`")?;
225 if output.status.success() {
226 return Ok(());
227 }
228 last_err = String::from_utf8_lossy(&output.stderr).trim().to_string();
229 }
230 bail!("`systemctl --user enable --now {SOCKET_UNIT}` failed: {last_err}");
231}
232
233fn write_units(dir: &Path, exe: &Path, socket: &Path, services: &ServiceSelection) -> Result<()> {
237 std::fs::create_dir_all(dir).with_context(|| format!("failed to create {}", dir.display()))?;
238 let service_unit = dir.join(SERVICE_UNIT);
239 let socket_unit = dir.join(SOCKET_UNIT);
240 std::fs::write(&service_unit, render_service(exe, socket, services)?)
241 .with_context(|| format!("failed to write {}", service_unit.display()))?;
242 std::fs::write(&socket_unit, render_socket(socket)?)
243 .with_context(|| format!("failed to write {}", socket_unit.display()))?;
244 Ok(())
245}
246
247pub fn install_and_load(socket: &Path, services: &ServiceSelection) -> Result<()> {
250 let exe = std::env::current_exe().context("could not resolve the current executable")?;
251 write_units(&unit_dir()?, &exe, socket, services)?;
252
253 if let Some(parent) = socket.parent() {
258 paths::ensure_dir_0700(parent)?;
259 }
260
261 daemon_reload(&spawn_systemctl)?;
262 enable_now(&spawn_systemctl)
263}
264
265fn run_best_effort(run: &Systemctl, args: &[&str]) {
271 if let Err(e) = run(args) {
272 tracing::warn!("failed to run `systemctl --user {}`: {e}", args.join(" "));
273 }
274}
275
276pub fn unload() -> Result<()> {
283 unload_with(&spawn_systemctl)
284}
285
286fn unload_with(run: &Systemctl) -> Result<()> {
289 run_best_effort(run, &["stop", SOCKET_UNIT, SERVICE_UNIT]);
292 run_best_effort(run, &["disable", SOCKET_UNIT]);
293 Ok(())
294}
295
296#[allow(unsafe_code)]
305fn set_cloexec(fd: RawFd) -> Result<()> {
306 use nix::libc;
307
308 let flags = unsafe { libc::fcntl(fd, libc::F_GETFD) };
311 if flags == -1 {
312 return Err(std::io::Error::last_os_error())
313 .context("F_GETFD on the systemd-activated socket failed");
314 }
315 let rc = unsafe { libc::fcntl(fd, libc::F_SETFD, flags | libc::FD_CLOEXEC) };
316 if rc == -1 {
317 return Err(std::io::Error::last_os_error())
318 .context("setting FD_CLOEXEC on the systemd-activated socket failed");
319 }
320 Ok(())
321}
322
323#[allow(unsafe_code)]
337pub(crate) fn systemd_listener() -> Result<Option<UnixListener>> {
338 const SD_LISTEN_FDS_START: RawFd = 3;
340
341 let for_us = std::env::var("LISTEN_PID")
343 .ok()
344 .and_then(|pid| pid.parse::<u32>().ok())
345 .is_some_and(|pid| pid == std::process::id());
346 if !for_us {
347 return Ok(None);
348 }
349 let count = std::env::var("LISTEN_FDS")
350 .ok()
351 .and_then(|n| n.parse::<i32>().ok())
352 .unwrap_or(0);
353 if count < 1 {
354 return Ok(None);
355 }
356
357 let raw = SD_LISTEN_FDS_START;
358 set_cloexec(raw)?;
359
360 let std_listener = unsafe { StdUnixListener::from_raw_fd(raw) };
365 std_listener
366 .set_nonblocking(true)
367 .context("failed to set the systemd socket non-blocking")?;
368 let listener = UnixListener::from_std(std_listener)
369 .context("failed to adopt the systemd socket into the Tokio runtime")?;
370 Ok(Some(listener))
371}
372
373#[cfg(test)]
374#[allow(clippy::unwrap_used, clippy::expect_used)]
375mod tests {
376 use super::*;
377 use crate::daemon::DaemonServiceKind;
378
379 fn assert_well_formed(unit: &str) {
384 for line in unit.lines() {
385 if line.is_empty() {
386 continue;
387 }
388 let is_section = line.starts_with('[') && line.ends_with(']');
389 let is_directive = line.contains('=');
390 assert!(
391 is_section || is_directive,
392 "malformed systemd unit line: {line:?}\n---\n{unit}"
393 );
394 }
395 }
396
397 #[test]
398 fn renders_plain_paths_verbatim() {
399 let service = render_service(
400 Path::new("/usr/local/bin/omni-dev"),
401 Path::new("/tmp/omni-dev/daemon.sock"),
402 &ServiceSelection::All,
403 )
404 .expect("ASCII paths render");
405
406 assert!(
407 service.contains(
408 "ExecStart=\"/usr/local/bin/omni-dev\" daemon run --socket \"/tmp/omni-dev/daemon.sock\""
409 ),
410 "{service}"
411 );
412 assert!(
413 service.contains(&format!("Requires={SOCKET_UNIT}")),
414 "{service}"
415 );
416 assert_well_formed(&service);
417 }
418
419 #[test]
420 fn bakes_a_service_subset_onto_exec_start() {
421 let service = render_service(
425 Path::new("/usr/local/bin/omni-dev"),
426 Path::new("/tmp/omni-dev/daemon.sock"),
427 &ServiceSelection::Only(vec![
428 DaemonServiceKind::Worktrees,
429 DaemonServiceKind::Sessions,
430 ]),
431 )
432 .expect("ASCII paths render");
433
434 assert!(
435 service.contains(
436 "daemon run --socket \"/tmp/omni-dev/daemon.sock\" --services \"worktrees,sessions\""
437 ),
438 "{service}"
439 );
440 assert_well_formed(&service);
441 }
442
443 #[test]
444 fn omits_services_when_hosting_all() {
445 let service = render_service(
448 Path::new("/usr/local/bin/omni-dev"),
449 Path::new("/tmp/omni-dev/daemon.sock"),
450 &ServiceSelection::All,
451 )
452 .expect("ASCII paths render");
453 assert!(!service.contains("--services"), "{service}");
454 }
455
456 #[test]
457 fn service_has_no_install_or_restart() {
458 let service = render_service(
461 Path::new("/usr/local/bin/omni-dev"),
462 Path::new("/tmp/omni-dev/daemon.sock"),
463 &ServiceSelection::All,
464 )
465 .expect("ASCII paths render");
466
467 assert!(!service.contains("Restart"), "{service}");
468 assert!(!service.contains("KeepAlive"), "{service}");
469 assert!(!service.contains("[Install]"), "{service}");
470 }
471
472 #[test]
473 fn exec_start_quotes_paths_with_spaces() {
474 let service = render_service(
477 Path::new("/opt/A B/omni-dev"),
478 Path::new("/tmp/omni-dev/daemon.sock"),
479 &ServiceSelection::All,
480 )
481 .expect("spaced path renders");
482
483 assert!(
484 service.contains("ExecStart=\"/opt/A B/omni-dev\" daemon run"),
485 "{service}"
486 );
487 assert_well_formed(&service);
488 }
489
490 #[test]
491 fn escapes_percent_specifier() {
492 let service = render_service(
494 Path::new("/usr/local/bin/omni-dev"),
495 Path::new("/tmp/50%/daemon.sock"),
496 &ServiceSelection::All,
497 )
498 .expect("percent path renders");
499 assert!(
500 service.contains("--socket \"/tmp/50%%/daemon.sock\""),
501 "{service}"
502 );
503
504 let socket = render_socket(Path::new("/tmp/50%/daemon.sock")).expect("renders");
505 assert!(
506 socket.contains("ListenStream=/tmp/50%%/daemon.sock"),
507 "{socket}"
508 );
509 assert!(!socket.contains("50%/"), "{socket}");
510 assert_well_formed(&socket);
511 }
512
513 #[test]
514 fn passes_xml_metacharacters_through_literally() {
515 let service = render_service(
518 Path::new("/usr/local/bin/omni-dev"),
519 Path::new("/tmp/a&b<c>d/daemon.sock"),
520 &ServiceSelection::All,
521 )
522 .expect("renders");
523 assert!(service.contains("/tmp/a&b<c>d/daemon.sock"), "{service}");
524 assert!(!service.contains("&"), "{service}");
525 assert_well_formed(&service);
526 }
527
528 #[test]
529 fn renders_a_socket_activated_unit() {
530 let socket = render_socket(Path::new("/tmp/omni-dev/daemon.sock")).expect("renders");
531 assert!(socket.contains("[Socket]"), "{socket}");
532 assert!(
533 socket.contains("ListenStream=/tmp/omni-dev/daemon.sock"),
534 "{socket}"
535 );
536 assert!(socket.contains("SocketMode=0600"), "{socket}");
537 assert!(socket.contains("WantedBy=sockets.target"), "{socket}");
538 assert_well_formed(&socket);
539 }
540
541 #[test]
542 fn rejects_non_utf8_paths() {
543 use std::ffi::OsStr;
544 use std::os::unix::ffi::OsStrExt;
545
546 let bad = Path::new(OsStr::from_bytes(b"/tmp/\xFF/omni-dev"));
547 assert!(
548 render_service(bad, Path::new("/tmp/daemon.sock"), &ServiceSelection::All).is_err()
549 );
550 assert!(
551 render_service(Path::new("/usr/bin/omni-dev"), bad, &ServiceSelection::All).is_err()
552 );
553 assert!(render_socket(bad).is_err());
554 }
555
556 #[test]
557 fn rejects_newline_in_path() {
558 let bad = Path::new("/tmp/a\nb/daemon.sock");
559 assert!(
560 render_service(Path::new("/usr/bin/omni-dev"), bad, &ServiceSelection::All).is_err()
561 );
562 assert!(render_socket(bad).is_err());
563 }
564
565 #[test]
570 fn systemd_listener_is_none_when_not_activated() {
571 let result = systemd_listener();
572 assert!(
573 matches!(result, Ok(None)),
574 "expected Ok(None) outside systemd activation, got {result:?}"
575 );
576 }
577
578 #[test]
579 fn unit_dir_sits_under_systemd_user() {
580 let dir = unit_dir().expect("config dir resolves in test");
581 assert!(dir.ends_with("systemd/user"), "{}", dir.display());
582 }
583
584 #[test]
585 fn write_units_lays_down_both_unit_files() {
586 let tmp = tempfile::tempdir().unwrap();
587 let unit_dir = tmp.path().join("systemd").join("user");
588 write_units(
589 &unit_dir,
590 Path::new("/usr/local/bin/omni-dev"),
591 Path::new("/tmp/omni-dev/daemon.sock"),
592 &ServiceSelection::All,
593 )
594 .expect("units are written");
595
596 let service = std::fs::read_to_string(unit_dir.join(SERVICE_UNIT)).unwrap();
597 let socket = std::fs::read_to_string(unit_dir.join(SOCKET_UNIT)).unwrap();
598 assert!(
599 service.contains("ExecStart=\"/usr/local/bin/omni-dev\" daemon run --socket \"/tmp/omni-dev/daemon.sock\""),
600 "{service}"
601 );
602 assert!(
603 socket.contains("ListenStream=/tmp/omni-dev/daemon.sock"),
604 "{socket}"
605 );
606 assert!(socket.contains("WantedBy=sockets.target"), "{socket}");
607 }
608
609 #[test]
610 fn systemctl_targets_the_user_manager() {
611 let cmd = systemctl(&["daemon-reload"]);
612 assert_eq!(cmd.get_program().to_str(), Some("systemctl"));
613 let args: Vec<_> = cmd.get_args().map(|a| a.to_str().unwrap()).collect();
614 assert_eq!(args, ["--user", "daemon-reload"]);
615 }
616
617 fn fake_output(raw: i32, stderr: &str) -> std::process::Output {
620 use std::os::unix::process::ExitStatusExt;
621 std::process::Output {
622 status: std::process::ExitStatus::from_raw(raw),
623 stdout: Vec::new(),
624 stderr: stderr.as_bytes().to_vec(),
625 }
626 }
627
628 #[test]
629 fn daemon_reload_reports_success_and_failure() {
630 daemon_reload(&|args| {
631 assert_eq!(args, ["daemon-reload"]);
632 Ok(fake_output(0, ""))
633 })
634 .expect("a zero-exit reload succeeds");
635
636 let err =
637 daemon_reload(&|_| Ok(fake_output(256, "boom"))).expect_err("a non-zero reload fails");
638 assert!(err.to_string().contains("boom"), "{err}");
639
640 assert!(
641 daemon_reload(&|_| Err(std::io::Error::other("no systemctl"))).is_err(),
642 "a spawn error must surface"
643 );
644 }
645
646 #[test]
647 fn enable_now_succeeds_on_the_first_attempt() {
648 let calls = std::cell::Cell::new(0);
649 enable_now(&|args| {
650 calls.set(calls.get() + 1);
651 assert_eq!(args, ["enable", "--now", SOCKET_UNIT]);
652 Ok(fake_output(0, ""))
653 })
654 .expect("first-attempt success");
655 assert_eq!(calls.get(), 1, "no retries on success");
656 }
657
658 #[test]
659 fn enable_now_retries_then_bails() {
660 let calls = std::cell::Cell::new(0);
661 let err = enable_now(&|_| {
662 calls.set(calls.get() + 1);
663 Ok(fake_output(256, "still failing"))
664 })
665 .expect_err("all attempts fail");
666 assert_eq!(calls.get(), 5, "retries the full budget");
667 assert!(err.to_string().contains("still failing"), "{err}");
668 }
669
670 #[test]
671 fn unload_stops_then_disables_best_effort() {
672 let seen: std::cell::RefCell<Vec<Vec<String>>> = std::cell::RefCell::new(Vec::new());
673 unload_with(&|args| {
674 seen.borrow_mut()
675 .push(args.iter().map(|s| (*s).to_string()).collect());
676 Ok(fake_output(0, ""))
677 })
678 .expect("unload is infallible");
679 let seen = seen.into_inner();
680 assert_eq!(seen[0], ["stop", SOCKET_UNIT, SERVICE_UNIT]);
681 assert_eq!(seen[1], ["disable", SOCKET_UNIT]);
682 }
683
684 #[test]
685 fn unload_swallows_a_missing_systemctl() {
686 unload_with(&|_| Err(std::io::Error::other("no systemctl")))
688 .expect("best-effort teardown never fails");
689 }
690
691 #[test]
692 fn flag_is_truthy_recognises_on_and_off_values() {
693 for on in ["1", "true", "TRUE", " yes ", "On"] {
694 assert!(flag_is_truthy(on), "{on:?} should be truthy");
695 }
696 for off in ["0", "false", "no", "", "off", "disable"] {
697 assert!(!flag_is_truthy(off), "{off:?} should be falsey");
698 }
699 }
700
701 #[test]
702 fn is_available_is_a_total_bool() {
703 let _ = is_available();
706 }
707
708 #[allow(unsafe_code)]
711 #[test]
712 fn set_cloexec_sets_the_flag() {
713 use std::os::unix::io::AsRawFd;
714
715 use nix::libc;
716
717 let dir = tempfile::tempdir().unwrap();
718 let listener = StdUnixListener::bind(dir.path().join("s.sock")).unwrap();
719 let fd = listener.as_raw_fd();
720
721 let cleared = unsafe { libc::fcntl(fd, libc::F_SETFD, 0) };
726 assert_eq!(cleared, 0, "clearing FD_CLOEXEC failed");
727 assert_eq!(
728 unsafe { libc::fcntl(fd, libc::F_GETFD) } & libc::FD_CLOEXEC,
729 0,
730 "precondition: FD_CLOEXEC should be cleared"
731 );
732
733 set_cloexec(fd).expect("set_cloexec succeeds on a valid fd");
734
735 assert_ne!(
736 unsafe { libc::fcntl(fd, libc::F_GETFD) } & libc::FD_CLOEXEC,
737 0,
738 "FD_CLOEXEC should be set after set_cloexec"
739 );
740 }
741
742 #[test]
743 fn set_cloexec_errors_on_a_bad_fd() {
744 assert!(set_cloexec(-1).is_err());
747 }
748}