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;
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) -> Result<String> {
133 let exe = validate_path(exe, "executable")?;
134 let socket = validate_path(socket, "socket")?;
135 Ok(format!(
136 r"[Unit]
137Description=omni-dev daemon
138Requires={socket_unit}
139After={socket_unit}
140
141[Service]
142ExecStart={exe} daemon run --socket {socket}
143",
144 socket_unit = SOCKET_UNIT,
145 exe = exec_arg(exe),
146 socket = exec_arg(socket),
147 ))
148}
149
150fn render_socket(socket: &Path) -> Result<String> {
158 let socket = validate_path(socket, "socket")?;
159 Ok(format!(
160 r"[Unit]
161Description=omni-dev daemon control socket
162
163[Socket]
164ListenStream={socket}
165SocketMode=0600
166
167[Install]
168WantedBy=sockets.target
169",
170 socket = escape_value(socket),
171 ))
172}
173
174fn systemctl(args: &[&str]) -> Command {
176 let mut command = Command::new("systemctl");
177 command.arg("--user").args(args);
178 command
179}
180
181type Systemctl<'a> = dyn Fn(&[&str]) -> std::io::Result<std::process::Output> + 'a;
186
187fn spawn_systemctl(args: &[&str]) -> std::io::Result<std::process::Output> {
189 systemctl(args).output()
190}
191
192fn daemon_reload(run: &Systemctl) -> Result<()> {
194 let output =
195 run(&["daemon-reload"]).context("failed to run `systemctl --user daemon-reload`")?;
196 if !output.status.success() {
197 bail!(
198 "`systemctl --user daemon-reload` failed: {}",
199 String::from_utf8_lossy(&output.stderr).trim()
200 );
201 }
202 Ok(())
203}
204
205fn enable_now(run: &Systemctl) -> Result<()> {
209 let mut last_err = String::new();
210 for attempt in 0..5 {
211 if attempt > 0 {
212 std::thread::sleep(Duration::from_millis(200));
213 }
214 let output = run(&["enable", "--now", SOCKET_UNIT])
215 .context("failed to run `systemctl --user enable --now`")?;
216 if output.status.success() {
217 return Ok(());
218 }
219 last_err = String::from_utf8_lossy(&output.stderr).trim().to_string();
220 }
221 bail!("`systemctl --user enable --now {SOCKET_UNIT}` failed: {last_err}");
222}
223
224fn write_units(dir: &Path, exe: &Path, socket: &Path) -> Result<()> {
228 std::fs::create_dir_all(dir).with_context(|| format!("failed to create {}", dir.display()))?;
229 let service_unit = dir.join(SERVICE_UNIT);
230 let socket_unit = dir.join(SOCKET_UNIT);
231 std::fs::write(&service_unit, render_service(exe, socket)?)
232 .with_context(|| format!("failed to write {}", service_unit.display()))?;
233 std::fs::write(&socket_unit, render_socket(socket)?)
234 .with_context(|| format!("failed to write {}", socket_unit.display()))?;
235 Ok(())
236}
237
238pub fn install_and_load(socket: &Path) -> Result<()> {
241 let exe = std::env::current_exe().context("could not resolve the current executable")?;
242 write_units(&unit_dir()?, &exe, socket)?;
243
244 if let Some(parent) = socket.parent() {
249 paths::ensure_dir_0700(parent)?;
250 }
251
252 daemon_reload(&spawn_systemctl)?;
253 enable_now(&spawn_systemctl)
254}
255
256fn run_best_effort(run: &Systemctl, args: &[&str]) {
262 if let Err(e) = run(args) {
263 tracing::warn!("failed to run `systemctl --user {}`: {e}", args.join(" "));
264 }
265}
266
267pub fn unload() -> Result<()> {
274 unload_with(&spawn_systemctl)
275}
276
277fn unload_with(run: &Systemctl) -> Result<()> {
280 run_best_effort(run, &["stop", SOCKET_UNIT, SERVICE_UNIT]);
283 run_best_effort(run, &["disable", SOCKET_UNIT]);
284 Ok(())
285}
286
287#[allow(unsafe_code)]
296fn set_cloexec(fd: RawFd) -> Result<()> {
297 use nix::libc;
298
299 let flags = unsafe { libc::fcntl(fd, libc::F_GETFD) };
302 if flags == -1 {
303 return Err(std::io::Error::last_os_error())
304 .context("F_GETFD on the systemd-activated socket failed");
305 }
306 let rc = unsafe { libc::fcntl(fd, libc::F_SETFD, flags | libc::FD_CLOEXEC) };
307 if rc == -1 {
308 return Err(std::io::Error::last_os_error())
309 .context("setting FD_CLOEXEC on the systemd-activated socket failed");
310 }
311 Ok(())
312}
313
314#[allow(unsafe_code)]
328pub(crate) fn systemd_listener() -> Result<Option<UnixListener>> {
329 const SD_LISTEN_FDS_START: RawFd = 3;
331
332 let for_us = std::env::var("LISTEN_PID")
334 .ok()
335 .and_then(|pid| pid.parse::<u32>().ok())
336 .is_some_and(|pid| pid == std::process::id());
337 if !for_us {
338 return Ok(None);
339 }
340 let count = std::env::var("LISTEN_FDS")
341 .ok()
342 .and_then(|n| n.parse::<i32>().ok())
343 .unwrap_or(0);
344 if count < 1 {
345 return Ok(None);
346 }
347
348 let raw = SD_LISTEN_FDS_START;
349 set_cloexec(raw)?;
350
351 let std_listener = unsafe { StdUnixListener::from_raw_fd(raw) };
356 std_listener
357 .set_nonblocking(true)
358 .context("failed to set the systemd socket non-blocking")?;
359 let listener = UnixListener::from_std(std_listener)
360 .context("failed to adopt the systemd socket into the Tokio runtime")?;
361 Ok(Some(listener))
362}
363
364#[cfg(test)]
365#[allow(clippy::unwrap_used, clippy::expect_used)]
366mod tests {
367 use super::*;
368
369 fn assert_well_formed(unit: &str) {
374 for line in unit.lines() {
375 if line.is_empty() {
376 continue;
377 }
378 let is_section = line.starts_with('[') && line.ends_with(']');
379 let is_directive = line.contains('=');
380 assert!(
381 is_section || is_directive,
382 "malformed systemd unit line: {line:?}\n---\n{unit}"
383 );
384 }
385 }
386
387 #[test]
388 fn renders_plain_paths_verbatim() {
389 let service = render_service(
390 Path::new("/usr/local/bin/omni-dev"),
391 Path::new("/tmp/omni-dev/daemon.sock"),
392 )
393 .expect("ASCII paths render");
394
395 assert!(
396 service.contains(
397 "ExecStart=\"/usr/local/bin/omni-dev\" daemon run --socket \"/tmp/omni-dev/daemon.sock\""
398 ),
399 "{service}"
400 );
401 assert!(
402 service.contains(&format!("Requires={SOCKET_UNIT}")),
403 "{service}"
404 );
405 assert_well_formed(&service);
406 }
407
408 #[test]
409 fn service_has_no_install_or_restart() {
410 let service = render_service(
413 Path::new("/usr/local/bin/omni-dev"),
414 Path::new("/tmp/omni-dev/daemon.sock"),
415 )
416 .expect("ASCII paths render");
417
418 assert!(!service.contains("Restart"), "{service}");
419 assert!(!service.contains("KeepAlive"), "{service}");
420 assert!(!service.contains("[Install]"), "{service}");
421 }
422
423 #[test]
424 fn exec_start_quotes_paths_with_spaces() {
425 let service = render_service(
428 Path::new("/opt/A B/omni-dev"),
429 Path::new("/tmp/omni-dev/daemon.sock"),
430 )
431 .expect("spaced path renders");
432
433 assert!(
434 service.contains("ExecStart=\"/opt/A B/omni-dev\" daemon run"),
435 "{service}"
436 );
437 assert_well_formed(&service);
438 }
439
440 #[test]
441 fn escapes_percent_specifier() {
442 let service = render_service(
444 Path::new("/usr/local/bin/omni-dev"),
445 Path::new("/tmp/50%/daemon.sock"),
446 )
447 .expect("percent path renders");
448 assert!(
449 service.contains("--socket \"/tmp/50%%/daemon.sock\""),
450 "{service}"
451 );
452
453 let socket = render_socket(Path::new("/tmp/50%/daemon.sock")).expect("renders");
454 assert!(
455 socket.contains("ListenStream=/tmp/50%%/daemon.sock"),
456 "{socket}"
457 );
458 assert!(!socket.contains("50%/"), "{socket}");
459 assert_well_formed(&socket);
460 }
461
462 #[test]
463 fn passes_xml_metacharacters_through_literally() {
464 let service = render_service(
467 Path::new("/usr/local/bin/omni-dev"),
468 Path::new("/tmp/a&b<c>d/daemon.sock"),
469 )
470 .expect("renders");
471 assert!(service.contains("/tmp/a&b<c>d/daemon.sock"), "{service}");
472 assert!(!service.contains("&"), "{service}");
473 assert_well_formed(&service);
474 }
475
476 #[test]
477 fn renders_a_socket_activated_unit() {
478 let socket = render_socket(Path::new("/tmp/omni-dev/daemon.sock")).expect("renders");
479 assert!(socket.contains("[Socket]"), "{socket}");
480 assert!(
481 socket.contains("ListenStream=/tmp/omni-dev/daemon.sock"),
482 "{socket}"
483 );
484 assert!(socket.contains("SocketMode=0600"), "{socket}");
485 assert!(socket.contains("WantedBy=sockets.target"), "{socket}");
486 assert_well_formed(&socket);
487 }
488
489 #[test]
490 fn rejects_non_utf8_paths() {
491 use std::ffi::OsStr;
492 use std::os::unix::ffi::OsStrExt;
493
494 let bad = Path::new(OsStr::from_bytes(b"/tmp/\xFF/omni-dev"));
495 assert!(render_service(bad, Path::new("/tmp/daemon.sock")).is_err());
496 assert!(render_service(Path::new("/usr/bin/omni-dev"), bad).is_err());
497 assert!(render_socket(bad).is_err());
498 }
499
500 #[test]
501 fn rejects_newline_in_path() {
502 let bad = Path::new("/tmp/a\nb/daemon.sock");
503 assert!(render_service(Path::new("/usr/bin/omni-dev"), bad).is_err());
504 assert!(render_socket(bad).is_err());
505 }
506
507 #[test]
512 fn systemd_listener_is_none_when_not_activated() {
513 let result = systemd_listener();
514 assert!(
515 matches!(result, Ok(None)),
516 "expected Ok(None) outside systemd activation, got {result:?}"
517 );
518 }
519
520 #[test]
521 fn unit_dir_sits_under_systemd_user() {
522 let dir = unit_dir().expect("config dir resolves in test");
523 assert!(dir.ends_with("systemd/user"), "{}", dir.display());
524 }
525
526 #[test]
527 fn write_units_lays_down_both_unit_files() {
528 let tmp = tempfile::tempdir().unwrap();
529 let unit_dir = tmp.path().join("systemd").join("user");
530 write_units(
531 &unit_dir,
532 Path::new("/usr/local/bin/omni-dev"),
533 Path::new("/tmp/omni-dev/daemon.sock"),
534 )
535 .expect("units are written");
536
537 let service = std::fs::read_to_string(unit_dir.join(SERVICE_UNIT)).unwrap();
538 let socket = std::fs::read_to_string(unit_dir.join(SOCKET_UNIT)).unwrap();
539 assert!(
540 service.contains("ExecStart=\"/usr/local/bin/omni-dev\" daemon run --socket \"/tmp/omni-dev/daemon.sock\""),
541 "{service}"
542 );
543 assert!(
544 socket.contains("ListenStream=/tmp/omni-dev/daemon.sock"),
545 "{socket}"
546 );
547 assert!(socket.contains("WantedBy=sockets.target"), "{socket}");
548 }
549
550 #[test]
551 fn systemctl_targets_the_user_manager() {
552 let cmd = systemctl(&["daemon-reload"]);
553 assert_eq!(cmd.get_program().to_str(), Some("systemctl"));
554 let args: Vec<_> = cmd.get_args().map(|a| a.to_str().unwrap()).collect();
555 assert_eq!(args, ["--user", "daemon-reload"]);
556 }
557
558 fn fake_output(raw: i32, stderr: &str) -> std::process::Output {
561 use std::os::unix::process::ExitStatusExt;
562 std::process::Output {
563 status: std::process::ExitStatus::from_raw(raw),
564 stdout: Vec::new(),
565 stderr: stderr.as_bytes().to_vec(),
566 }
567 }
568
569 #[test]
570 fn daemon_reload_reports_success_and_failure() {
571 daemon_reload(&|args| {
572 assert_eq!(args, ["daemon-reload"]);
573 Ok(fake_output(0, ""))
574 })
575 .expect("a zero-exit reload succeeds");
576
577 let err =
578 daemon_reload(&|_| Ok(fake_output(256, "boom"))).expect_err("a non-zero reload fails");
579 assert!(err.to_string().contains("boom"), "{err}");
580
581 assert!(
582 daemon_reload(&|_| Err(std::io::Error::other("no systemctl"))).is_err(),
583 "a spawn error must surface"
584 );
585 }
586
587 #[test]
588 fn enable_now_succeeds_on_the_first_attempt() {
589 let calls = std::cell::Cell::new(0);
590 enable_now(&|args| {
591 calls.set(calls.get() + 1);
592 assert_eq!(args, ["enable", "--now", SOCKET_UNIT]);
593 Ok(fake_output(0, ""))
594 })
595 .expect("first-attempt success");
596 assert_eq!(calls.get(), 1, "no retries on success");
597 }
598
599 #[test]
600 fn enable_now_retries_then_bails() {
601 let calls = std::cell::Cell::new(0);
602 let err = enable_now(&|_| {
603 calls.set(calls.get() + 1);
604 Ok(fake_output(256, "still failing"))
605 })
606 .expect_err("all attempts fail");
607 assert_eq!(calls.get(), 5, "retries the full budget");
608 assert!(err.to_string().contains("still failing"), "{err}");
609 }
610
611 #[test]
612 fn unload_stops_then_disables_best_effort() {
613 let seen: std::cell::RefCell<Vec<Vec<String>>> = std::cell::RefCell::new(Vec::new());
614 unload_with(&|args| {
615 seen.borrow_mut()
616 .push(args.iter().map(|s| (*s).to_string()).collect());
617 Ok(fake_output(0, ""))
618 })
619 .expect("unload is infallible");
620 let seen = seen.into_inner();
621 assert_eq!(seen[0], ["stop", SOCKET_UNIT, SERVICE_UNIT]);
622 assert_eq!(seen[1], ["disable", SOCKET_UNIT]);
623 }
624
625 #[test]
626 fn unload_swallows_a_missing_systemctl() {
627 unload_with(&|_| Err(std::io::Error::other("no systemctl")))
629 .expect("best-effort teardown never fails");
630 }
631
632 #[test]
633 fn flag_is_truthy_recognises_on_and_off_values() {
634 for on in ["1", "true", "TRUE", " yes ", "On"] {
635 assert!(flag_is_truthy(on), "{on:?} should be truthy");
636 }
637 for off in ["0", "false", "no", "", "off", "disable"] {
638 assert!(!flag_is_truthy(off), "{off:?} should be falsey");
639 }
640 }
641
642 #[test]
643 fn is_available_is_a_total_bool() {
644 let _ = is_available();
647 }
648
649 #[allow(unsafe_code)]
652 #[test]
653 fn set_cloexec_sets_the_flag() {
654 use std::os::unix::io::AsRawFd;
655
656 use nix::libc;
657
658 let dir = tempfile::tempdir().unwrap();
659 let listener = StdUnixListener::bind(dir.path().join("s.sock")).unwrap();
660 let fd = listener.as_raw_fd();
661
662 let cleared = unsafe { libc::fcntl(fd, libc::F_SETFD, 0) };
667 assert_eq!(cleared, 0, "clearing FD_CLOEXEC failed");
668 assert_eq!(
669 unsafe { libc::fcntl(fd, libc::F_GETFD) } & libc::FD_CLOEXEC,
670 0,
671 "precondition: FD_CLOEXEC should be cleared"
672 );
673
674 set_cloexec(fd).expect("set_cloexec succeeds on a valid fd");
675
676 assert_ne!(
677 unsafe { libc::fcntl(fd, libc::F_GETFD) } & libc::FD_CLOEXEC,
678 0,
679 "FD_CLOEXEC should be set after set_cloexec"
680 );
681 }
682
683 #[test]
684 fn set_cloexec_errors_on_a_bad_fd() {
685 assert!(set_cloexec(-1).is_err());
688 }
689}