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 fn write_registration(launcher: &AutoLaunch) -> Result<()> {
98 #[cfg(target_os = "macos")]
101 let path = current_plist_path()?;
102 #[cfg(target_os = "macos")]
103 let previous = match std::fs::read(&path) {
104 Ok(previous) => Some(previous),
105 Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
106 Err(e) => {
107 warn!("cannot read the existing launchd plist to keep its restart policy: {e}");
108 None
109 }
110 };
111 launcher.enable().into_diagnostic()?;
112 #[cfg(target_os = "macos")]
113 add_keep_alive(&path, previous.as_deref())?;
114 Ok(())
115 }
116
117 #[cfg(target_os = "macos")]
119 fn current_plist_path() -> Result<std::path::PathBuf> {
120 if nix::unistd::Uid::effective().is_root() {
121 return Ok(SYSTEM_REGISTRATION.into());
122 }
123 let home =
124 dirs::home_dir().ok_or_else(|| miette::miette!("failed to find home directory"))?;
125 Ok(home.join("Library/LaunchAgents/pitchfork.plist"))
126 }
127
128 #[cfg(target_os = "macos")]
129 fn add_keep_alive(path: &std::path::Path, previous: Option<&[u8]>) -> Result<()> {
130 let mut contents = std::fs::read(path).into_diagnostic()?;
131 let mut changed = false;
132 if let Some(restored) =
133 previous.and_then(|p| super::launchd_with_restart_policy_of(&contents, p))
134 {
135 contents = restored;
136 changed = true;
137 }
138 if let Some(updated) = super::launchd_with_keep_alive(&contents) {
139 contents = updated;
140 changed = true;
141 }
142 if changed {
143 std::fs::write(path, contents).into_diagnostic()?;
144 }
145 Ok(())
146 }
147
148 impl BootManager {
149 pub fn new() -> Result<Self> {
152 #[cfg(any(target_os = "macos", target_os = "linux"))]
153 return Self::with_invoking_user(env::boot_service_invoking_user()?);
154 #[cfg(windows)]
155 Self::with_invoking_user(None)
156 }
157
158 fn with_invoking_user(invoking_user: Option<String>) -> Result<Self> {
162 let configured = crate::settings::settings().boot.executable.clone();
163 let explicit_executable = !configured.is_empty();
164 let app_path = if configured.is_empty() {
165 env::PITCHFORK_BIN.to_string_lossy().to_string()
166 } else {
167 configured
168 };
169
170 #[cfg(any(target_os = "macos", target_os = "linux"))]
171 let (current_args, other_args) =
172 (service_args(invoking_user.as_deref()), service_args(None));
173
174 #[cfg(target_os = "macos")]
175 let (current, other, legacy) = {
176 let is_root = nix::unistd::Uid::effective().is_root();
177 let (current_mode, other_mode) = if is_root {
178 (
179 MacOSLaunchMode::LaunchDaemonSystem,
180 MacOSLaunchMode::LaunchAgentUser,
181 )
182 } else {
183 (
184 MacOSLaunchMode::LaunchAgentUser,
185 MacOSLaunchMode::LaunchDaemonSystem,
186 )
187 };
188 (
189 build_launcher(&app_path, ¤t_args, current_mode)?,
190 build_launcher(&app_path, &other_args, other_mode)?,
191 build_launcher(&app_path, &other_args, MacOSLaunchMode::LaunchAgentSystem)?,
192 )
193 };
194
195 #[cfg(target_os = "linux")]
196 let (current, other) = {
197 let is_root = nix::unistd::Uid::effective().is_root();
198 let (current_mode, other_mode) = if is_root {
199 (LinuxLaunchMode::SystemdSystem, LinuxLaunchMode::SystemdUser)
200 } else {
201 (LinuxLaunchMode::SystemdUser, LinuxLaunchMode::SystemdSystem)
202 };
203 (
204 build_launcher(&app_path, ¤t_args, current_mode)?,
205 build_launcher(&app_path, &other_args, other_mode)?,
206 )
207 };
208
209 #[cfg(windows)]
212 let (current, other) = (
213 AutoLaunchBuilder::new()
214 .set_app_name("pitchfork")
215 .set_app_path(&app_path)
216 .set_args(&["supervisor", "run", "--boot"])
217 .build()
218 .into_diagnostic()?,
219 AutoLaunchBuilder::new()
220 .set_app_name("pitchfork")
221 .set_app_path(&app_path)
222 .set_args(&["supervisor", "run", "--boot"])
223 .build()
224 .into_diagnostic()?,
225 );
226
227 #[cfg(target_os = "macos")]
228 return Ok(Self {
229 app_path,
230 explicit_executable,
231 invoking_user,
232 current,
233 other,
234 legacy,
235 });
236
237 #[cfg(not(target_os = "macos"))]
238 Ok(Self {
239 app_path,
240 explicit_executable,
241 invoking_user,
242 current,
243 other,
244 })
245 }
246
247 fn validate_executable(&self) -> Result<()> {
250 if !self.explicit_executable {
251 return Ok(());
252 }
253 let invalid = |reason: &str| {
254 miette::miette!(
255 "settings.boot.executable '{}': {reason}; set an absolute path to an \
256 existing executable, or unset the setting to use the running binary",
257 self.app_path
258 )
259 };
260 let path = std::path::Path::new(&self.app_path);
261 if !path.is_absolute() {
262 return Err(invalid(
263 "path must be absolute (no PATH or tilde expansion)",
264 ));
265 }
266 if self.app_path.chars().any(char::is_control) {
267 return Err(invalid("path must not contain control characters"));
268 }
269 #[cfg(any(target_os = "linux", windows))]
273 if self.app_path.chars().any(char::is_whitespace) {
274 return Err(invalid(
275 "boot registration does not support whitespace in paths on this platform",
276 ));
277 }
278 #[cfg(target_os = "linux")]
279 if self.app_path.contains(['\"', '\'', '\\', '%', '$']) {
280 return Err(invalid(
281 "boot registration does not support quotes, backslashes, percent signs or dollar signs in systemd executable paths",
282 ));
283 }
284 let metadata = std::fs::metadata(path)
285 .map_err(|e| invalid(&format!("cannot access executable: {e}")))?;
286 if !metadata.is_file() {
287 return Err(invalid("path is not a regular file"));
288 }
289 #[cfg(unix)]
290 {
291 let path = std::ffi::CString::new(self.app_path.as_bytes())
292 .map_err(|_| invalid("path contains a NUL byte"))?;
293 if unsafe { libc::access(path.as_ptr(), libc::X_OK) } != 0 {
296 return Err(invalid("file is not executable by this user"));
297 }
298 }
299 Ok(())
300 }
301
302 pub fn invoking_user(&self) -> Option<&str> {
304 self.invoking_user.as_deref()
305 }
306
307 pub fn is_system_level_enabled(&self) -> Result<bool> {
309 #[cfg(any(target_os = "macos", target_os = "linux"))]
310 let system = if nix::unistd::Uid::effective().is_root() {
311 &self.current
312 } else {
313 &self.other
314 };
315 #[cfg(any(target_os = "macos", target_os = "linux"))]
316 return system.is_enabled().into_diagnostic();
317 #[cfg(windows)]
318 Ok(false)
319 }
320
321 pub fn system_invoking_user(&self) -> Option<String> {
324 #[cfg(any(target_os = "macos", target_os = "linux"))]
325 return registered_system_invoking_user();
326 #[cfg(windows)]
327 None
328 }
329
330 pub fn is_current_level_up_to_date(&self) -> Result<bool> {
333 self.validate_executable()?;
334 let registered = self.current.get_registered_app_path().into_diagnostic()?;
335 if registered.as_deref() != Some(self.app_path.as_str()) {
336 return Ok(false);
337 }
338 #[cfg(target_os = "macos")]
340 {
341 let plist = std::fs::read(current_plist_path()?).into_diagnostic()?;
342 if super::launchd_with_keep_alive(&plist).is_some() {
343 return Ok(false);
344 }
345 }
346 #[cfg(any(target_os = "macos", target_os = "linux"))]
347 if nix::unistd::Uid::effective().is_root() {
348 return Ok(registered_system_invoking_user() == self.invoking_user);
349 }
350 Ok(true)
351 }
352
353 pub fn is_enabled(&self) -> Result<bool> {
355 #[cfg(target_os = "macos")]
356 return Ok(self.current.is_enabled().into_diagnostic()?
357 || self.other.is_enabled().into_diagnostic()?
358 || self.legacy.is_enabled().into_diagnostic()?);
359
360 #[cfg(not(target_os = "macos"))]
361 Ok(self.current.is_enabled().into_diagnostic()?
362 || self.other.is_enabled().into_diagnostic()?)
363 }
364
365 pub fn is_current_level_enabled(&self) -> Result<bool> {
367 self.current.is_enabled().into_diagnostic()
368 }
369
370 pub fn is_other_level_enabled(&self) -> Result<bool> {
375 #[cfg(target_os = "macos")]
376 return Ok(self.other.is_enabled().into_diagnostic()?
377 || (!nix::unistd::Uid::effective().is_root()
378 && self.legacy.is_enabled().into_diagnostic()?));
379
380 #[cfg(not(target_os = "macos"))]
381 self.other.is_enabled().into_diagnostic()
382 }
383
384 #[cfg(target_os = "macos")]
391 pub fn cleanup_legacy(&self, migrated: bool) -> Result<()> {
392 if nix::unistd::Uid::effective().is_root()
393 && self.legacy.is_enabled().into_diagnostic()?
394 {
395 self.legacy.disable().into_diagnostic()?;
396 if migrated {
397 info!(
398 "migrated legacy system-level launch entry from /Library/LaunchAgents/ to /Library/LaunchDaemons/"
399 );
400 } else {
401 info!("removed legacy system-level launch entry from /Library/LaunchAgents/");
402 }
403 }
404 Ok(())
405 }
406
407 pub fn enable(&self) -> Result<()> {
415 self.validate_executable()?;
416 #[cfg(target_os = "macos")]
419 let other_conflict = if nix::unistd::Uid::effective().is_root() {
420 self.other.is_enabled().into_diagnostic()?
421 } else {
422 self.is_other_level_enabled()?
423 };
424
425 #[cfg(not(target_os = "macos"))]
426 let other_conflict = self.other.is_enabled().into_diagnostic()?;
427
428 if other_conflict {
429 miette::bail!(
430 "boot start is already registered at the other privilege level; \
431 run `pitchfork boot disable` (with appropriate privileges) to remove \
432 it first"
433 );
434 }
435
436 write_registration(&self.current)?;
437
438 #[cfg(target_os = "macos")]
439 self.cleanup_legacy(true)?;
440
441 Ok(())
442 }
443
444 pub fn refresh(&self) -> Result<()> {
447 self.validate_executable()?;
448 write_registration(&self.current)?;
449
450 #[cfg(target_os = "macos")]
451 self.cleanup_legacy(false)?;
452
453 Ok(())
454 }
455
456 pub fn disable(&self) -> Result<()> {
462 if self.current.is_enabled().into_diagnostic()? {
463 self.current.disable().into_diagnostic()?;
464 }
465 if self.other.is_enabled().into_diagnostic()? {
466 self.other.disable().into_diagnostic()?;
467 }
468 #[cfg(target_os = "macos")]
469 if nix::unistd::Uid::effective().is_root()
470 && self.legacy.is_enabled().into_diagnostic()?
471 {
472 self.legacy.disable().into_diagnostic()?;
473 }
474 Ok(())
475 }
476
477 pub fn check_and_reregister_if_stale(&self) {
486 if let Err(e) = self.validate_executable() {
487 warn!("cannot repair boot registration: {e}");
488 return;
489 }
490 let current_bin = &self.app_path;
491
492 let registered = match self.current.get_registered_app_path() {
493 Ok(Some(path)) => path,
494 Ok(None) => return, Err(e) => {
496 warn!("failed to read registered boot path: {e}");
497 return;
498 }
499 };
500
501 if registered == *current_bin {
502 return; }
504
505 info!(
506 "boot registration points to stale binary path '{registered}', \
507 re-registering with current path '{current_bin}'"
508 );
509
510 #[cfg(any(target_os = "macos", target_os = "linux"))]
514 let preserved = if nix::unistd::Uid::effective().is_root() {
515 let registered = registered_system_invoking_user();
516 if registered == self.invoking_user {
517 None
518 } else {
519 match Self::with_invoking_user(registered) {
520 Ok(manager) => Some(manager),
521 Err(e) => {
522 warn!("failed to prepare boot re-registration: {e}");
523 return;
524 }
525 }
526 }
527 } else {
528 None
529 };
530 #[cfg(windows)]
531 let preserved: Option<Self> = None;
532 let launcher = preserved.as_ref().map_or(&self.current, |m| &m.current);
533
534 if let Err(e) = write_registration(launcher) {
539 warn!("failed to re-register boot start with current path: {e}");
540 return;
541 }
542
543 info!("boot registration updated to current binary path");
544 }
545 }
546
547 #[cfg(all(test, target_os = "linux"))]
548 mod tests {
549 use super::BootManager;
550 use std::os::unix::fs::{PermissionsExt, symlink};
551
552 #[test]
553 fn refresh_preserves_executable_when_invoking_user_changes() {
554 assert!(
556 !nix::unistd::Uid::effective().is_root(),
557 "run this test as non-root"
558 );
559 if std::env::var_os("PITCHFORK_BOOT_METADATA_TEST_CHILD").is_none() {
560 let home = tempfile::tempdir().unwrap();
561 let bin = home.path().join("bin");
562 std::fs::create_dir(&bin).unwrap();
563 let systemctl = bin.join("systemctl");
564 std::fs::write(&systemctl, "#!/bin/sh\ncase \"$*\" in\n'--user daemon-reload'|'--user enable pitchfork.service') exit 0 ;;\n*) exit 99 ;;\nesac\n").unwrap();
565 std::fs::set_permissions(&systemctl, std::fs::Permissions::from_mode(0o755))
566 .unwrap();
567 symlink(std::env::current_exe().unwrap(), bin.join("stable")).unwrap();
568 let output = std::process::Command::new(std::env::current_exe().unwrap())
570 .args(["--exact", "boot_manager::imp::tests::refresh_preserves_executable_when_invoking_user_changes", "--nocapture"])
571 .current_dir(home.path())
572 .env("HOME", home.path())
573 .env("PATH", &bin)
574 .env("PITCHFORK_CONFIG_DIR", home.path())
575 .env("PITCHFORK_BOOT_EXECUTABLE", bin.join("stable"))
576 .env("PITCHFORK_BOOT_METADATA_TEST_CHILD", "1")
577 .output().unwrap();
578 assert!(
579 output.status.success(),
580 "{}\n{}",
581 String::from_utf8_lossy(&output.stdout),
582 String::from_utf8_lossy(&output.stderr)
583 );
584 return;
585 }
586
587 let home = std::path::PathBuf::from(std::env::var_os("HOME").unwrap());
591 let stable = home.join("bin/stable");
592 let unit = home.join(".config/systemd/user/pitchfork.service");
593 for user in [Some("alice"), Some("bob"), None] {
594 let manager = BootManager::with_invoking_user(user.map(String::from)).unwrap();
595 manager.refresh().unwrap();
596 let contents = std::fs::read_to_string(&unit).unwrap();
597 let expected = match user {
598 Some(user) => format!(
599 "ExecStart={} supervisor run --boot --invoking-user {user}\n",
600 stable.display()
601 ),
602 None => format!("ExecStart={} supervisor run --boot\n", stable.display()),
603 };
604 assert!(contents.contains(&expected), "{contents}");
605 }
606 }
607 }
608}
609
610#[cfg(not(any(target_os = "macos", target_os = "linux", windows)))]
613mod imp {
614 use crate::Result;
615
616 pub struct BootManager;
617
618 impl BootManager {
619 pub fn new() -> Result<Self> {
620 miette::bail!(
621 "boot management is not supported on this platform; \
622 only macOS, Linux, and Windows are supported"
623 );
624 }
625
626 pub fn is_enabled(&self) -> Result<bool> {
627 miette::bail!(
628 "boot management is not supported on this platform; \
629 only macOS, Linux, and Windows are supported"
630 );
631 }
632
633 pub fn is_current_level_enabled(&self) -> Result<bool> {
634 miette::bail!(
635 "boot management is not supported on this platform; \
636 only macOS, Linux, and Windows are supported"
637 );
638 }
639
640 pub fn is_other_level_enabled(&self) -> Result<bool> {
641 miette::bail!(
642 "boot management is not supported on this platform; \
643 only macOS, Linux, and Windows are supported"
644 );
645 }
646
647 pub fn enable(&self) -> Result<()> {
648 miette::bail!(
649 "boot management is not supported on this platform; \
650 only macOS, Linux, and Windows are supported"
651 );
652 }
653
654 pub fn refresh(&self) -> Result<()> {
655 miette::bail!(
656 "boot management is not supported on this platform; \
657 only macOS, Linux, and Windows are supported"
658 );
659 }
660
661 pub fn invoking_user(&self) -> Option<&str> {
662 None
663 }
664
665 pub fn is_system_level_enabled(&self) -> Result<bool> {
666 Ok(false)
667 }
668
669 pub fn system_invoking_user(&self) -> Option<String> {
670 None
671 }
672
673 pub fn is_current_level_up_to_date(&self) -> Result<bool> {
674 Ok(false)
675 }
676
677 pub fn disable(&self) -> Result<()> {
678 miette::bail!(
679 "boot management is not supported on this platform; \
680 only macOS, Linux, and Windows are supported"
681 );
682 }
683 }
684}
685
686pub use imp::BootManager;
687
688#[cfg(any(target_os = "linux", all(test, target_os = "macos")))]
691fn systemd_exec_start(unit: &str) -> Option<Vec<String>> {
692 unit.lines()
693 .find_map(|line| line.trim().strip_prefix("ExecStart="))
694 .map(|command| command.split_whitespace().map(String::from).collect())
695}
696
697#[cfg(any(target_os = "macos", all(test, target_os = "linux")))]
699fn launchd_program_arguments(plist: &[u8]) -> Option<Vec<String>> {
700 let value = plist::Value::from_reader(std::io::Cursor::new(plist)).ok()?;
701 value
702 .as_dictionary()?
703 .get("ProgramArguments")?
704 .as_array()?
705 .iter()
706 .map(|arg| arg.as_string().map(String::from))
707 .collect()
708}
709
710#[cfg(any(target_os = "macos", all(test, target_os = "linux")))]
712const LAUNCHD_RESTART_KEYS: [&str; 2] = ["KeepAlive", "ThrottleInterval"];
713
714#[cfg(any(target_os = "macos", all(test, target_os = "linux")))]
720fn launchd_with_restart_policy_of(plist: &[u8], previous: &[u8]) -> Option<Vec<u8>> {
721 let previous = plist::Value::from_reader(std::io::Cursor::new(previous)).ok()?;
722 let previous = previous.as_dictionary()?;
723 let mut value = plist::Value::from_reader(std::io::Cursor::new(plist)).ok()?;
724 let dict = value.as_dictionary_mut()?;
725 let mut changed = false;
726 for key in LAUNCHD_RESTART_KEYS {
727 if let Some(policy) = previous.get(key)
728 && dict.get(key) != Some(policy)
729 {
730 dict.insert(key.into(), policy.clone());
731 changed = true;
732 }
733 }
734 if !changed {
735 return None;
736 }
737 let mut out = Vec::new();
738 plist::to_writer_xml(&mut out, &value).ok()?;
739 Some(out)
740}
741
742#[cfg(any(target_os = "macos", all(test, target_os = "linux")))]
753fn launchd_with_keep_alive(plist: &[u8]) -> Option<Vec<u8>> {
754 let mut value = plist::Value::from_reader(std::io::Cursor::new(plist)).ok()?;
755 let dict = value.as_dictionary_mut()?;
756 if dict.contains_key("KeepAlive") {
757 return None;
758 }
759 let mut keep_alive = plist::Dictionary::new();
760 keep_alive.insert("SuccessfulExit".into(), plist::Value::Boolean(false));
761 dict.insert("KeepAlive".into(), plist::Value::Dictionary(keep_alive));
762 if !dict.contains_key("ThrottleInterval") {
763 dict.insert("ThrottleInterval".into(), plist::Value::Integer(10.into()));
764 }
765 let mut out = Vec::new();
766 plist::to_writer_xml(&mut out, &value).ok()?;
767 Some(out)
768}
769
770#[cfg(all(test, any(target_os = "macos", target_os = "linux")))]
771mod tests {
772 use super::imp::service_args;
773 use super::{
774 launchd_program_arguments, launchd_with_keep_alive, launchd_with_restart_policy_of,
775 systemd_exec_start,
776 };
777 use crate::env::invoking_user_arg;
778
779 fn argv(args: Option<Vec<String>>) -> Vec<std::ffi::OsString> {
780 args.unwrap().into_iter().map(Into::into).collect()
781 }
782
783 #[test]
785 fn invoking_user_is_read_back_from_systemd_unit() {
786 let unit = "[Unit]\nDescription=pitchfork\nAfter=multi-user.target\n\n\
787 [Service]\nType=simple\n\
788 ExecStart=/usr/local/bin/pitchfork supervisor run --boot --invoking-user alice\n\
789 Restart=on-failure\n";
790 let args = systemd_exec_start(unit);
791 assert_eq!(invoking_user_arg(argv(args)).as_deref(), Some("alice"));
792
793 let legacy = "[Service]\nExecStart=/usr/local/bin/pitchfork supervisor run --boot\n";
794 assert_eq!(invoking_user_arg(argv(systemd_exec_start(legacy))), None);
795 assert_eq!(systemd_exec_start("[Service]\n"), None);
796 }
797
798 #[test]
800 fn invoking_user_is_read_back_from_launchd_plist() {
801 let plist = br#"<?xml version="1.0" encoding="UTF-8"?>
802<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
803<plist version="1.0">
804<dict>
805 <key>Label</key>
806 <string>pitchfork</string>
807 <key>ProgramArguments</key>
808 <array>
809 <string>/opt/homebrew/bin/pitchfork</string>
810 <string>supervisor</string>
811 <string>run</string>
812 <string>--boot</string>
813 <string>--invoking-user</string>
814 <string>alice</string>
815 </array>
816 <key>RunAtLoad</key>
817 <true/>
818 <key>SessionCreate</key>
819 <true/>
820</dict>
821</plist>"#;
822 let args = launchd_program_arguments(plist);
823 assert_eq!(invoking_user_arg(argv(args)).as_deref(), Some("alice"));
824 assert_eq!(launchd_program_arguments(b"not a plist"), None);
825 }
826
827 #[test]
828 fn service_args_without_invoking_user_keep_plain_boot_command() {
829 assert_eq!(service_args(None), ["supervisor", "run", "--boot"]);
830 }
831
832 #[test]
833 fn service_args_record_invoking_user() {
834 let args = service_args(Some("alice"));
835 assert_eq!(
836 args,
837 ["supervisor", "run", "--boot", "--invoking-user", "alice"]
838 );
839 let argv = std::iter::once("pitchfork".to_string())
842 .chain(args)
843 .map(std::ffi::OsString::from);
844 assert_eq!(
845 crate::env::invoking_user_arg(argv).as_deref(),
846 Some("alice")
847 );
848 }
849
850 #[test]
851 fn launchd_plist_gains_crash_restart_keys_once() {
852 let plist = br#"<?xml version="1.0" encoding="UTF-8"?>
853<plist version="1.0">
854<dict>
855 <key>Label</key><string>pitchfork</string>
856 <key>ProgramArguments</key><array><string>/bin/pitchfork</string><string>supervisor</string></array>
857 <key>RunAtLoad</key><true/>
858</dict>
859</plist>"#;
860 let updated = launchd_with_keep_alive(plist).unwrap();
861 let value = plist::Value::from_reader(std::io::Cursor::new(&updated)).unwrap();
862 let dict = value.as_dictionary().unwrap();
863 let keep_alive = dict.get("KeepAlive").unwrap().as_dictionary().unwrap();
864 assert_eq!(
865 keep_alive.get("SuccessfulExit").unwrap().as_boolean(),
866 Some(false)
867 );
868 assert_eq!(
869 dict.get("ThrottleInterval").unwrap().as_signed_integer(),
870 Some(10)
871 );
872 assert_eq!(dict.get("RunAtLoad").unwrap().as_boolean(), Some(true));
873 assert_eq!(launchd_program_arguments(&updated).unwrap().len(), 2);
874 assert_eq!(launchd_with_keep_alive(&updated), None);
875 assert_eq!(launchd_with_keep_alive(b"not a plist"), None);
876 }
877
878 #[test]
881 fn launchd_rewrite_keeps_the_previous_restart_policy() {
882 let rewritten = br#"<?xml version="1.0" encoding="UTF-8"?>
883<plist version="1.0">
884<dict>
885 <key>Label</key><string>pitchfork</string>
886 <key>ProgramArguments</key><array><string>/new/pitchfork</string></array>
887</dict>
888</plist>"#;
889 let previous = br#"<?xml version="1.0" encoding="UTF-8"?>
890<plist version="1.0">
891<dict>
892 <key>Label</key><string>pitchfork</string>
893 <key>ProgramArguments</key><array><string>/old/pitchfork</string></array>
894 <key>KeepAlive</key><true/>
895 <key>ThrottleInterval</key><integer>30</integer>
896</dict>
897</plist>"#;
898 let restored = launchd_with_restart_policy_of(rewritten, previous).unwrap();
899 let value = plist::Value::from_reader(std::io::Cursor::new(&restored)).unwrap();
900 let dict = value.as_dictionary().unwrap();
901 assert_eq!(dict.get("KeepAlive").unwrap().as_boolean(), Some(true));
902 assert_eq!(
903 dict.get("ThrottleInterval").unwrap().as_signed_integer(),
904 Some(30)
905 );
906 assert_eq!(
907 launchd_program_arguments(&restored).unwrap(),
908 vec!["/new/pitchfork".to_string()]
909 );
910 assert_eq!(launchd_with_keep_alive(&restored), None);
911
912 let no_policy = br#"<?xml version="1.0" encoding="UTF-8"?>
914<plist version="1.0"><dict><key>Label</key><string>pitchfork</string></dict></plist>"#;
915 assert_eq!(launchd_with_restart_policy_of(rewritten, no_policy), None);
916 assert_eq!(
917 launchd_with_restart_policy_of(rewritten, b"not a plist"),
918 None
919 );
920 assert!(launchd_with_keep_alive(rewritten).is_some());
921 }
922
923 #[test]
926 fn launchd_plist_keeps_a_local_keep_alive_policy() {
927 let plist = br#"<?xml version="1.0" encoding="UTF-8"?>
928<plist version="1.0">
929<dict>
930 <key>Label</key><string>pitchfork</string>
931 <key>ProgramArguments</key><array><string>/bin/pitchfork</string><string>supervisor</string></array>
932 <key>KeepAlive</key><true/>
933 <key>ExitTimeOut</key><integer>240</integer>
934</dict>
935</plist>"#;
936 assert_eq!(launchd_with_keep_alive(plist), None);
937
938 let plist = br#"<?xml version="1.0" encoding="UTF-8"?>
940<plist version="1.0">
941<dict>
942 <key>Label</key><string>pitchfork</string>
943 <key>ThrottleInterval</key><integer>30</integer>
944</dict>
945</plist>"#;
946 let updated = launchd_with_keep_alive(plist).unwrap();
947 let value = plist::Value::from_reader(std::io::Cursor::new(&updated)).unwrap();
948 let dict = value.as_dictionary().unwrap();
949 assert!(dict.get("KeepAlive").unwrap().as_dictionary().is_some());
950 assert_eq!(
951 dict.get("ThrottleInterval").unwrap().as_signed_integer(),
952 Some(30)
953 );
954 }
955}