1#[cfg(any(target_os = "macos", target_os = "linux", windows))]
4mod imp {
5 use crate::{Result, env};
6 #[cfg(target_os = "linux")]
7 use auto_launcher::LinuxLaunchMode;
8 #[cfg(target_os = "macos")]
9 use auto_launcher::MacOSLaunchMode;
10 use auto_launcher::{AutoLaunch, AutoLaunchBuilder};
11 use miette::IntoDiagnostic;
12
13 #[cfg(any(target_os = "macos", target_os = "linux"))]
18 pub(crate) fn service_args(invoking_user: Option<&str>) -> Vec<String> {
19 let mut args: Vec<String> = ["supervisor", "run", "--boot"]
20 .into_iter()
21 .map(String::from)
22 .collect();
23 if let Some(user) = invoking_user {
24 args.push(env::INVOKING_USER_FLAG.to_string());
25 args.push(user.to_string());
26 }
27 args
28 }
29
30 #[cfg(any(target_os = "macos", target_os = "linux"))]
31 fn build_launcher(
32 app_path: &str,
33 args: &[String],
34 #[cfg(target_os = "macos")] macos_mode: MacOSLaunchMode,
35 #[cfg(target_os = "linux")] linux_mode: LinuxLaunchMode,
36 ) -> Result<AutoLaunch> {
37 let mut builder = AutoLaunchBuilder::new();
38 builder
39 .set_app_name("pitchfork")
40 .set_app_path(app_path)
41 .set_args(args);
42
43 #[cfg(target_os = "macos")]
44 builder.set_macos_launch_mode(macos_mode);
45
46 #[cfg(target_os = "linux")]
47 builder.set_linux_launch_mode(linux_mode);
48
49 builder.build().into_diagnostic()
50 }
51
52 pub struct BootManager {
53 app_path: String,
55 explicit_executable: bool,
56 invoking_user: Option<String>,
58 current: AutoLaunch,
60 other: AutoLaunch,
62 #[cfg(target_os = "macos")]
65 legacy: AutoLaunch,
66 }
67
68 #[cfg(target_os = "macos")]
70 const SYSTEM_REGISTRATION: &str = "/Library/LaunchDaemons/pitchfork.plist";
71 #[cfg(target_os = "linux")]
72 const SYSTEM_REGISTRATION: &str = "/etc/systemd/system/pitchfork.service";
73
74 #[cfg(any(target_os = "macos", target_os = "linux"))]
77 fn registered_system_invoking_user() -> Option<String> {
78 let contents = match std::fs::read(SYSTEM_REGISTRATION) {
79 Ok(contents) => contents,
80 Err(err) => {
81 if err.kind() != std::io::ErrorKind::NotFound {
82 warn!("failed to read {SYSTEM_REGISTRATION}: {err}");
83 }
84 return None;
85 }
86 };
87 #[cfg(target_os = "macos")]
88 let argv = super::launchd_program_arguments(&contents);
89 #[cfg(target_os = "linux")]
90 let argv = super::systemd_exec_start(&String::from_utf8_lossy(&contents));
91 env::invoking_user_arg(argv?.into_iter().map(Into::into))
92 }
93
94 impl BootManager {
95 pub fn new() -> Result<Self> {
98 #[cfg(any(target_os = "macos", target_os = "linux"))]
99 return Self::with_invoking_user(env::boot_service_invoking_user()?);
100 #[cfg(windows)]
101 Self::with_invoking_user(None)
102 }
103
104 fn with_invoking_user(invoking_user: Option<String>) -> Result<Self> {
108 let configured = crate::settings::settings().boot.executable.clone();
109 let explicit_executable = !configured.is_empty();
110 let app_path = if configured.is_empty() {
111 env::PITCHFORK_BIN.to_string_lossy().to_string()
112 } else {
113 configured
114 };
115
116 #[cfg(any(target_os = "macos", target_os = "linux"))]
117 let (current_args, other_args) =
118 (service_args(invoking_user.as_deref()), service_args(None));
119
120 #[cfg(target_os = "macos")]
121 let (current, other, legacy) = {
122 let is_root = nix::unistd::Uid::effective().is_root();
123 let (current_mode, other_mode) = if is_root {
124 (
125 MacOSLaunchMode::LaunchDaemonSystem,
126 MacOSLaunchMode::LaunchAgentUser,
127 )
128 } else {
129 (
130 MacOSLaunchMode::LaunchAgentUser,
131 MacOSLaunchMode::LaunchDaemonSystem,
132 )
133 };
134 (
135 build_launcher(&app_path, ¤t_args, current_mode)?,
136 build_launcher(&app_path, &other_args, other_mode)?,
137 build_launcher(&app_path, &other_args, MacOSLaunchMode::LaunchAgentSystem)?,
138 )
139 };
140
141 #[cfg(target_os = "linux")]
142 let (current, other) = {
143 let is_root = nix::unistd::Uid::effective().is_root();
144 let (current_mode, other_mode) = if is_root {
145 (LinuxLaunchMode::SystemdSystem, LinuxLaunchMode::SystemdUser)
146 } else {
147 (LinuxLaunchMode::SystemdUser, LinuxLaunchMode::SystemdSystem)
148 };
149 (
150 build_launcher(&app_path, ¤t_args, current_mode)?,
151 build_launcher(&app_path, &other_args, other_mode)?,
152 )
153 };
154
155 #[cfg(windows)]
158 let (current, other) = (
159 AutoLaunchBuilder::new()
160 .set_app_name("pitchfork")
161 .set_app_path(&app_path)
162 .set_args(&["supervisor", "run", "--boot"])
163 .build()
164 .into_diagnostic()?,
165 AutoLaunchBuilder::new()
166 .set_app_name("pitchfork")
167 .set_app_path(&app_path)
168 .set_args(&["supervisor", "run", "--boot"])
169 .build()
170 .into_diagnostic()?,
171 );
172
173 #[cfg(target_os = "macos")]
174 return Ok(Self {
175 app_path,
176 explicit_executable,
177 invoking_user,
178 current,
179 other,
180 legacy,
181 });
182
183 #[cfg(not(target_os = "macos"))]
184 Ok(Self {
185 app_path,
186 explicit_executable,
187 invoking_user,
188 current,
189 other,
190 })
191 }
192
193 fn validate_executable(&self) -> Result<()> {
196 if !self.explicit_executable {
197 return Ok(());
198 }
199 let invalid = |reason: &str| {
200 miette::miette!(
201 "settings.boot.executable '{}': {reason}; set an absolute path to an \
202 existing executable, or unset the setting to use the running binary",
203 self.app_path
204 )
205 };
206 let path = std::path::Path::new(&self.app_path);
207 if !path.is_absolute() {
208 return Err(invalid(
209 "path must be absolute (no PATH or tilde expansion)",
210 ));
211 }
212 if self.app_path.chars().any(char::is_control) {
213 return Err(invalid("path must not contain control characters"));
214 }
215 #[cfg(any(target_os = "linux", windows))]
219 if self.app_path.chars().any(char::is_whitespace) {
220 return Err(invalid(
221 "boot registration does not support whitespace in paths on this platform",
222 ));
223 }
224 #[cfg(target_os = "linux")]
225 if self.app_path.contains(['\"', '\'', '\\', '%', '$']) {
226 return Err(invalid(
227 "boot registration does not support quotes, backslashes, percent signs or dollar signs in systemd executable paths",
228 ));
229 }
230 let metadata = std::fs::metadata(path)
231 .map_err(|e| invalid(&format!("cannot access executable: {e}")))?;
232 if !metadata.is_file() {
233 return Err(invalid("path is not a regular file"));
234 }
235 #[cfg(unix)]
236 {
237 let path = std::ffi::CString::new(self.app_path.as_bytes())
238 .map_err(|_| invalid("path contains a NUL byte"))?;
239 if unsafe { libc::access(path.as_ptr(), libc::X_OK) } != 0 {
242 return Err(invalid("file is not executable by this user"));
243 }
244 }
245 Ok(())
246 }
247
248 pub fn invoking_user(&self) -> Option<&str> {
250 self.invoking_user.as_deref()
251 }
252
253 pub fn is_system_level_enabled(&self) -> Result<bool> {
255 #[cfg(any(target_os = "macos", target_os = "linux"))]
256 let system = if nix::unistd::Uid::effective().is_root() {
257 &self.current
258 } else {
259 &self.other
260 };
261 #[cfg(any(target_os = "macos", target_os = "linux"))]
262 return system.is_enabled().into_diagnostic();
263 #[cfg(windows)]
264 Ok(false)
265 }
266
267 pub fn system_invoking_user(&self) -> Option<String> {
270 #[cfg(any(target_os = "macos", target_os = "linux"))]
271 return registered_system_invoking_user();
272 #[cfg(windows)]
273 None
274 }
275
276 pub fn is_current_level_up_to_date(&self) -> Result<bool> {
279 self.validate_executable()?;
280 let registered = self.current.get_registered_app_path().into_diagnostic()?;
281 if registered.as_deref() != Some(self.app_path.as_str()) {
282 return Ok(false);
283 }
284 #[cfg(any(target_os = "macos", target_os = "linux"))]
285 if nix::unistd::Uid::effective().is_root() {
286 return Ok(registered_system_invoking_user() == self.invoking_user);
287 }
288 Ok(true)
289 }
290
291 pub fn is_enabled(&self) -> Result<bool> {
293 #[cfg(target_os = "macos")]
294 return Ok(self.current.is_enabled().into_diagnostic()?
295 || self.other.is_enabled().into_diagnostic()?
296 || self.legacy.is_enabled().into_diagnostic()?);
297
298 #[cfg(not(target_os = "macos"))]
299 Ok(self.current.is_enabled().into_diagnostic()?
300 || self.other.is_enabled().into_diagnostic()?)
301 }
302
303 pub fn is_current_level_enabled(&self) -> Result<bool> {
305 self.current.is_enabled().into_diagnostic()
306 }
307
308 pub fn is_other_level_enabled(&self) -> Result<bool> {
313 #[cfg(target_os = "macos")]
314 return Ok(self.other.is_enabled().into_diagnostic()?
315 || (!nix::unistd::Uid::effective().is_root()
316 && self.legacy.is_enabled().into_diagnostic()?));
317
318 #[cfg(not(target_os = "macos"))]
319 self.other.is_enabled().into_diagnostic()
320 }
321
322 #[cfg(target_os = "macos")]
329 pub fn cleanup_legacy(&self, migrated: bool) -> Result<()> {
330 if nix::unistd::Uid::effective().is_root()
331 && self.legacy.is_enabled().into_diagnostic()?
332 {
333 self.legacy.disable().into_diagnostic()?;
334 if migrated {
335 info!(
336 "migrated legacy system-level launch entry from /Library/LaunchAgents/ to /Library/LaunchDaemons/"
337 );
338 } else {
339 info!("removed legacy system-level launch entry from /Library/LaunchAgents/");
340 }
341 }
342 Ok(())
343 }
344
345 pub fn enable(&self) -> Result<()> {
353 self.validate_executable()?;
354 #[cfg(target_os = "macos")]
357 let other_conflict = if nix::unistd::Uid::effective().is_root() {
358 self.other.is_enabled().into_diagnostic()?
359 } else {
360 self.is_other_level_enabled()?
361 };
362
363 #[cfg(not(target_os = "macos"))]
364 let other_conflict = self.other.is_enabled().into_diagnostic()?;
365
366 if other_conflict {
367 miette::bail!(
368 "boot start is already registered at the other privilege level; \
369 run `pitchfork boot disable` (with appropriate privileges) to remove \
370 it first"
371 );
372 }
373
374 self.current.enable().into_diagnostic()?;
375
376 #[cfg(target_os = "macos")]
377 self.cleanup_legacy(true)?;
378
379 Ok(())
380 }
381
382 pub fn refresh(&self) -> Result<()> {
385 self.validate_executable()?;
386 self.current.enable().into_diagnostic()?;
387
388 #[cfg(target_os = "macos")]
389 self.cleanup_legacy(false)?;
390
391 Ok(())
392 }
393
394 pub fn disable(&self) -> Result<()> {
400 if self.current.is_enabled().into_diagnostic()? {
401 self.current.disable().into_diagnostic()?;
402 }
403 if self.other.is_enabled().into_diagnostic()? {
404 self.other.disable().into_diagnostic()?;
405 }
406 #[cfg(target_os = "macos")]
407 if nix::unistd::Uid::effective().is_root()
408 && self.legacy.is_enabled().into_diagnostic()?
409 {
410 self.legacy.disable().into_diagnostic()?;
411 }
412 Ok(())
413 }
414
415 pub fn check_and_reregister_if_stale(&self) {
424 if let Err(e) = self.validate_executable() {
425 warn!("cannot repair boot registration: {e}");
426 return;
427 }
428 let current_bin = &self.app_path;
429
430 let registered = match self.current.get_registered_app_path() {
431 Ok(Some(path)) => path,
432 Ok(None) => return, Err(e) => {
434 warn!("failed to read registered boot path: {e}");
435 return;
436 }
437 };
438
439 if registered == *current_bin {
440 return; }
442
443 info!(
444 "boot registration points to stale binary path '{registered}', \
445 re-registering with current path '{current_bin}'"
446 );
447
448 #[cfg(any(target_os = "macos", target_os = "linux"))]
452 let preserved = if nix::unistd::Uid::effective().is_root() {
453 let registered = registered_system_invoking_user();
454 if registered == self.invoking_user {
455 None
456 } else {
457 match Self::with_invoking_user(registered) {
458 Ok(manager) => Some(manager),
459 Err(e) => {
460 warn!("failed to prepare boot re-registration: {e}");
461 return;
462 }
463 }
464 }
465 } else {
466 None
467 };
468 #[cfg(windows)]
469 let preserved: Option<Self> = None;
470 let launcher = preserved.as_ref().map_or(&self.current, |m| &m.current);
471
472 if let Err(e) = launcher.enable() {
477 warn!("failed to re-register boot start with current path: {e}");
478 return;
479 }
480
481 info!("boot registration updated to current binary path");
482 }
483 }
484
485 #[cfg(all(test, target_os = "linux"))]
486 mod tests {
487 use super::BootManager;
488 use std::os::unix::fs::{PermissionsExt, symlink};
489
490 #[test]
491 fn refresh_preserves_executable_when_invoking_user_changes() {
492 assert!(
494 !nix::unistd::Uid::effective().is_root(),
495 "run this test as non-root"
496 );
497 if std::env::var_os("PITCHFORK_BOOT_METADATA_TEST_CHILD").is_none() {
498 let home = tempfile::tempdir().unwrap();
499 let bin = home.path().join("bin");
500 std::fs::create_dir(&bin).unwrap();
501 let systemctl = bin.join("systemctl");
502 std::fs::write(&systemctl, "#!/bin/sh\ncase \"$*\" in\n'--user daemon-reload'|'--user enable pitchfork.service') exit 0 ;;\n*) exit 99 ;;\nesac\n").unwrap();
503 std::fs::set_permissions(&systemctl, std::fs::Permissions::from_mode(0o755))
504 .unwrap();
505 symlink(std::env::current_exe().unwrap(), bin.join("stable")).unwrap();
506 let output = std::process::Command::new(std::env::current_exe().unwrap())
508 .args(["--exact", "boot_manager::imp::tests::refresh_preserves_executable_when_invoking_user_changes", "--nocapture"])
509 .current_dir(home.path())
510 .env("HOME", home.path())
511 .env("PATH", &bin)
512 .env("PITCHFORK_CONFIG_DIR", home.path())
513 .env("PITCHFORK_BOOT_EXECUTABLE", bin.join("stable"))
514 .env("PITCHFORK_BOOT_METADATA_TEST_CHILD", "1")
515 .output().unwrap();
516 assert!(
517 output.status.success(),
518 "{}\n{}",
519 String::from_utf8_lossy(&output.stdout),
520 String::from_utf8_lossy(&output.stderr)
521 );
522 return;
523 }
524
525 let home = std::path::PathBuf::from(std::env::var_os("HOME").unwrap());
529 let stable = home.join("bin/stable");
530 let unit = home.join(".config/systemd/user/pitchfork.service");
531 for user in [Some("alice"), Some("bob"), None] {
532 let manager = BootManager::with_invoking_user(user.map(String::from)).unwrap();
533 manager.refresh().unwrap();
534 let contents = std::fs::read_to_string(&unit).unwrap();
535 let expected = match user {
536 Some(user) => format!(
537 "ExecStart={} supervisor run --boot --invoking-user {user}\n",
538 stable.display()
539 ),
540 None => format!("ExecStart={} supervisor run --boot\n", stable.display()),
541 };
542 assert!(contents.contains(&expected), "{contents}");
543 }
544 }
545 }
546}
547
548#[cfg(not(any(target_os = "macos", target_os = "linux", windows)))]
551mod imp {
552 use crate::Result;
553
554 pub struct BootManager;
555
556 impl BootManager {
557 pub fn new() -> Result<Self> {
558 miette::bail!(
559 "boot management is not supported on this platform; \
560 only macOS, Linux, and Windows are supported"
561 )
562 }
563
564 pub fn is_enabled(&self) -> Result<bool> {
565 miette::bail!(
566 "boot management is not supported on this platform; \
567 only macOS, Linux, and Windows are supported"
568 )
569 }
570
571 pub fn is_current_level_enabled(&self) -> Result<bool> {
572 miette::bail!(
573 "boot management is not supported on this platform; \
574 only macOS, Linux, and Windows are supported"
575 )
576 }
577
578 pub fn is_other_level_enabled(&self) -> Result<bool> {
579 miette::bail!(
580 "boot management is not supported on this platform; \
581 only macOS, Linux, and Windows are supported"
582 )
583 }
584
585 pub fn enable(&self) -> Result<()> {
586 miette::bail!(
587 "boot management is not supported on this platform; \
588 only macOS, Linux, and Windows are supported"
589 )
590 }
591
592 pub fn refresh(&self) -> Result<()> {
593 miette::bail!(
594 "boot management is not supported on this platform; \
595 only macOS, Linux, and Windows are supported"
596 )
597 }
598
599 pub fn invoking_user(&self) -> Option<&str> {
600 None
601 }
602
603 pub fn is_system_level_enabled(&self) -> Result<bool> {
604 Ok(false)
605 }
606
607 pub fn system_invoking_user(&self) -> Option<String> {
608 None
609 }
610
611 pub fn is_current_level_up_to_date(&self) -> Result<bool> {
612 Ok(false)
613 }
614
615 pub fn disable(&self) -> Result<()> {
616 miette::bail!(
617 "boot management is not supported on this platform; \
618 only macOS, Linux, and Windows are supported"
619 )
620 }
621 }
622}
623
624pub use imp::BootManager;
625
626#[cfg(any(target_os = "linux", all(test, target_os = "macos")))]
629fn systemd_exec_start(unit: &str) -> Option<Vec<String>> {
630 unit.lines()
631 .find_map(|line| line.trim().strip_prefix("ExecStart="))
632 .map(|command| command.split_whitespace().map(String::from).collect())
633}
634
635#[cfg(any(target_os = "macos", all(test, target_os = "linux")))]
637fn launchd_program_arguments(plist: &[u8]) -> Option<Vec<String>> {
638 let value = plist::Value::from_reader(std::io::Cursor::new(plist)).ok()?;
639 value
640 .as_dictionary()?
641 .get("ProgramArguments")?
642 .as_array()?
643 .iter()
644 .map(|arg| arg.as_string().map(String::from))
645 .collect()
646}
647
648#[cfg(all(test, any(target_os = "macos", target_os = "linux")))]
649mod tests {
650 use super::imp::service_args;
651 use super::{launchd_program_arguments, systemd_exec_start};
652 use crate::env::invoking_user_arg;
653
654 fn argv(args: Option<Vec<String>>) -> Vec<std::ffi::OsString> {
655 args.unwrap().into_iter().map(Into::into).collect()
656 }
657
658 #[test]
660 fn invoking_user_is_read_back_from_systemd_unit() {
661 let unit = "[Unit]\nDescription=pitchfork\nAfter=multi-user.target\n\n\
662 [Service]\nType=simple\n\
663 ExecStart=/usr/local/bin/pitchfork supervisor run --boot --invoking-user alice\n\
664 Restart=on-failure\n";
665 let args = systemd_exec_start(unit);
666 assert_eq!(invoking_user_arg(argv(args)).as_deref(), Some("alice"));
667
668 let legacy = "[Service]\nExecStart=/usr/local/bin/pitchfork supervisor run --boot\n";
669 assert_eq!(invoking_user_arg(argv(systemd_exec_start(legacy))), None);
670 assert_eq!(systemd_exec_start("[Service]\n"), None);
671 }
672
673 #[test]
675 fn invoking_user_is_read_back_from_launchd_plist() {
676 let plist = br#"<?xml version="1.0" encoding="UTF-8"?>
677<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
678<plist version="1.0">
679<dict>
680 <key>Label</key>
681 <string>pitchfork</string>
682 <key>ProgramArguments</key>
683 <array>
684 <string>/opt/homebrew/bin/pitchfork</string>
685 <string>supervisor</string>
686 <string>run</string>
687 <string>--boot</string>
688 <string>--invoking-user</string>
689 <string>alice</string>
690 </array>
691 <key>RunAtLoad</key>
692 <true/>
693 <key>SessionCreate</key>
694 <true/>
695</dict>
696</plist>"#;
697 let args = launchd_program_arguments(plist);
698 assert_eq!(invoking_user_arg(argv(args)).as_deref(), Some("alice"));
699 assert_eq!(launchd_program_arguments(b"not a plist"), None);
700 }
701
702 #[test]
703 fn service_args_without_invoking_user_keep_plain_boot_command() {
704 assert_eq!(service_args(None), ["supervisor", "run", "--boot"]);
705 }
706
707 #[test]
708 fn service_args_record_invoking_user() {
709 let args = service_args(Some("alice"));
710 assert_eq!(
711 args,
712 ["supervisor", "run", "--boot", "--invoking-user", "alice"]
713 );
714 let argv = std::iter::once("pitchfork".to_string())
717 .chain(args)
718 .map(std::ffi::OsString::from);
719 assert_eq!(
720 crate::env::invoking_user_arg(argv).as_deref(),
721 Some("alice")
722 );
723 }
724}