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 invoking_user: Option<String>,
55 current: AutoLaunch,
57 other: AutoLaunch,
59 #[cfg(target_os = "macos")]
62 legacy: AutoLaunch,
63 }
64
65 #[cfg(target_os = "macos")]
67 const SYSTEM_REGISTRATION: &str = "/Library/LaunchDaemons/pitchfork.plist";
68 #[cfg(target_os = "linux")]
69 const SYSTEM_REGISTRATION: &str = "/etc/systemd/system/pitchfork.service";
70
71 #[cfg(any(target_os = "macos", target_os = "linux"))]
74 fn registered_system_invoking_user() -> Option<String> {
75 let contents = match std::fs::read(SYSTEM_REGISTRATION) {
76 Ok(contents) => contents,
77 Err(err) => {
78 if err.kind() != std::io::ErrorKind::NotFound {
79 warn!("failed to read {SYSTEM_REGISTRATION}: {err}");
80 }
81 return None;
82 }
83 };
84 #[cfg(target_os = "macos")]
85 let argv = super::launchd_program_arguments(&contents);
86 #[cfg(target_os = "linux")]
87 let argv = super::systemd_exec_start(&String::from_utf8_lossy(&contents));
88 env::invoking_user_arg(argv?.into_iter().map(Into::into))
89 }
90
91 impl BootManager {
92 pub fn new() -> Result<Self> {
95 #[cfg(any(target_os = "macos", target_os = "linux"))]
96 return Self::with_invoking_user(env::boot_service_invoking_user()?);
97 #[cfg(windows)]
98 Self::with_invoking_user(None)
99 }
100
101 fn with_invoking_user(invoking_user: Option<String>) -> Result<Self> {
105 let app_path = env::PITCHFORK_BIN.to_string_lossy().to_string();
106
107 #[cfg(any(target_os = "macos", target_os = "linux"))]
108 let (current_args, other_args) =
109 (service_args(invoking_user.as_deref()), service_args(None));
110
111 #[cfg(target_os = "macos")]
112 let (current, other, legacy) = {
113 let is_root = nix::unistd::Uid::effective().is_root();
114 let (current_mode, other_mode) = if is_root {
115 (
116 MacOSLaunchMode::LaunchDaemonSystem,
117 MacOSLaunchMode::LaunchAgentUser,
118 )
119 } else {
120 (
121 MacOSLaunchMode::LaunchAgentUser,
122 MacOSLaunchMode::LaunchDaemonSystem,
123 )
124 };
125 (
126 build_launcher(&app_path, ¤t_args, current_mode)?,
127 build_launcher(&app_path, &other_args, other_mode)?,
128 build_launcher(&app_path, &other_args, MacOSLaunchMode::LaunchAgentSystem)?,
129 )
130 };
131
132 #[cfg(target_os = "linux")]
133 let (current, other) = {
134 let is_root = nix::unistd::Uid::effective().is_root();
135 let (current_mode, other_mode) = if is_root {
136 (LinuxLaunchMode::SystemdSystem, LinuxLaunchMode::SystemdUser)
137 } else {
138 (LinuxLaunchMode::SystemdUser, LinuxLaunchMode::SystemdSystem)
139 };
140 (
141 build_launcher(&app_path, ¤t_args, current_mode)?,
142 build_launcher(&app_path, &other_args, other_mode)?,
143 )
144 };
145
146 #[cfg(windows)]
149 let (current, other) = (
150 AutoLaunchBuilder::new()
151 .set_app_name("pitchfork")
152 .set_app_path(&app_path)
153 .set_args(&["supervisor", "run", "--boot"])
154 .build()
155 .into_diagnostic()?,
156 AutoLaunchBuilder::new()
157 .set_app_name("pitchfork")
158 .set_app_path(&app_path)
159 .set_args(&["supervisor", "run", "--boot"])
160 .build()
161 .into_diagnostic()?,
162 );
163
164 #[cfg(target_os = "macos")]
165 return Ok(Self {
166 invoking_user,
167 current,
168 other,
169 legacy,
170 });
171
172 #[cfg(not(target_os = "macos"))]
173 Ok(Self {
174 invoking_user,
175 current,
176 other,
177 })
178 }
179
180 pub fn invoking_user(&self) -> Option<&str> {
182 self.invoking_user.as_deref()
183 }
184
185 pub fn is_system_level_enabled(&self) -> Result<bool> {
187 #[cfg(any(target_os = "macos", target_os = "linux"))]
188 let system = if nix::unistd::Uid::effective().is_root() {
189 &self.current
190 } else {
191 &self.other
192 };
193 #[cfg(any(target_os = "macos", target_os = "linux"))]
194 return system.is_enabled().into_diagnostic();
195 #[cfg(windows)]
196 Ok(false)
197 }
198
199 pub fn system_invoking_user(&self) -> Option<String> {
202 #[cfg(any(target_os = "macos", target_os = "linux"))]
203 return registered_system_invoking_user();
204 #[cfg(windows)]
205 None
206 }
207
208 pub fn is_current_level_up_to_date(&self) -> Result<bool> {
211 let current_bin = env::PITCHFORK_BIN.to_string_lossy();
212 let registered = self.current.get_registered_app_path().into_diagnostic()?;
213 if registered.as_deref() != Some(current_bin.as_ref()) {
214 return Ok(false);
215 }
216 #[cfg(any(target_os = "macos", target_os = "linux"))]
217 if nix::unistd::Uid::effective().is_root() {
218 return Ok(registered_system_invoking_user() == self.invoking_user);
219 }
220 Ok(true)
221 }
222
223 pub fn is_enabled(&self) -> Result<bool> {
225 #[cfg(target_os = "macos")]
226 return Ok(self.current.is_enabled().into_diagnostic()?
227 || self.other.is_enabled().into_diagnostic()?
228 || self.legacy.is_enabled().into_diagnostic()?);
229
230 #[cfg(not(target_os = "macos"))]
231 Ok(self.current.is_enabled().into_diagnostic()?
232 || self.other.is_enabled().into_diagnostic()?)
233 }
234
235 pub fn is_current_level_enabled(&self) -> Result<bool> {
237 self.current.is_enabled().into_diagnostic()
238 }
239
240 pub fn is_other_level_enabled(&self) -> Result<bool> {
245 #[cfg(target_os = "macos")]
246 return Ok(self.other.is_enabled().into_diagnostic()?
247 || (!nix::unistd::Uid::effective().is_root()
248 && self.legacy.is_enabled().into_diagnostic()?));
249
250 #[cfg(not(target_os = "macos"))]
251 self.other.is_enabled().into_diagnostic()
252 }
253
254 #[cfg(target_os = "macos")]
261 pub fn cleanup_legacy(&self, migrated: bool) -> Result<()> {
262 if nix::unistd::Uid::effective().is_root()
263 && self.legacy.is_enabled().into_diagnostic()?
264 {
265 self.legacy.disable().into_diagnostic()?;
266 if migrated {
267 info!(
268 "migrated legacy system-level launch entry from /Library/LaunchAgents/ to /Library/LaunchDaemons/"
269 );
270 } else {
271 info!("removed legacy system-level launch entry from /Library/LaunchAgents/");
272 }
273 }
274 Ok(())
275 }
276
277 pub fn enable(&self) -> Result<()> {
285 #[cfg(target_os = "macos")]
288 let other_conflict = if nix::unistd::Uid::effective().is_root() {
289 self.other.is_enabled().into_diagnostic()?
290 } else {
291 self.is_other_level_enabled()?
292 };
293
294 #[cfg(not(target_os = "macos"))]
295 let other_conflict = self.other.is_enabled().into_diagnostic()?;
296
297 if other_conflict {
298 miette::bail!(
299 "boot start is already registered at the other privilege level; \
300 run `pitchfork boot disable` (with appropriate privileges) to remove \
301 it first"
302 );
303 }
304
305 self.current.enable().into_diagnostic()?;
306
307 #[cfg(target_os = "macos")]
308 self.cleanup_legacy(true)?;
309
310 Ok(())
311 }
312
313 pub fn refresh(&self) -> Result<()> {
316 self.current.enable().into_diagnostic()?;
317
318 #[cfg(target_os = "macos")]
319 self.cleanup_legacy(false)?;
320
321 Ok(())
322 }
323
324 pub fn disable(&self) -> Result<()> {
330 if self.current.is_enabled().into_diagnostic()? {
331 self.current.disable().into_diagnostic()?;
332 }
333 if self.other.is_enabled().into_diagnostic()? {
334 self.other.disable().into_diagnostic()?;
335 }
336 #[cfg(target_os = "macos")]
337 if nix::unistd::Uid::effective().is_root()
338 && self.legacy.is_enabled().into_diagnostic()?
339 {
340 self.legacy.disable().into_diagnostic()?;
341 }
342 Ok(())
343 }
344
345 pub fn check_and_reregister_if_stale(&self) {
354 let current_bin = env::PITCHFORK_BIN.to_string_lossy().to_string();
355
356 let registered = match self.current.get_registered_app_path() {
357 Ok(Some(path)) => path,
358 Ok(None) => return, Err(e) => {
360 warn!("failed to read registered boot path: {e}");
361 return;
362 }
363 };
364
365 if registered == current_bin {
366 return; }
368
369 info!(
370 "boot registration points to stale binary path '{registered}', \
371 re-registering with current path '{current_bin}'"
372 );
373
374 #[cfg(any(target_os = "macos", target_os = "linux"))]
378 let preserved = if nix::unistd::Uid::effective().is_root() {
379 let registered = registered_system_invoking_user();
380 if registered == self.invoking_user {
381 None
382 } else {
383 match Self::with_invoking_user(registered) {
384 Ok(manager) => Some(manager),
385 Err(e) => {
386 warn!("failed to prepare boot re-registration: {e}");
387 return;
388 }
389 }
390 }
391 } else {
392 None
393 };
394 #[cfg(windows)]
395 let preserved: Option<Self> = None;
396 let launcher = preserved.as_ref().map_or(&self.current, |m| &m.current);
397
398 if let Err(e) = launcher.enable() {
403 warn!("failed to re-register boot start with current path: {e}");
404 return;
405 }
406
407 info!("boot registration updated to current binary path");
408 }
409 }
410}
411
412#[cfg(not(any(target_os = "macos", target_os = "linux", windows)))]
415mod imp {
416 use crate::Result;
417
418 pub struct BootManager;
419
420 impl BootManager {
421 pub fn new() -> Result<Self> {
422 miette::bail!(
423 "boot management is not supported on this platform; \
424 only macOS, Linux, and Windows are supported"
425 )
426 }
427
428 pub fn is_enabled(&self) -> Result<bool> {
429 miette::bail!(
430 "boot management is not supported on this platform; \
431 only macOS, Linux, and Windows are supported"
432 )
433 }
434
435 pub fn is_current_level_enabled(&self) -> Result<bool> {
436 miette::bail!(
437 "boot management is not supported on this platform; \
438 only macOS, Linux, and Windows are supported"
439 )
440 }
441
442 pub fn is_other_level_enabled(&self) -> Result<bool> {
443 miette::bail!(
444 "boot management is not supported on this platform; \
445 only macOS, Linux, and Windows are supported"
446 )
447 }
448
449 pub fn enable(&self) -> Result<()> {
450 miette::bail!(
451 "boot management is not supported on this platform; \
452 only macOS, Linux, and Windows are supported"
453 )
454 }
455
456 pub fn refresh(&self) -> Result<()> {
457 miette::bail!(
458 "boot management is not supported on this platform; \
459 only macOS, Linux, and Windows are supported"
460 )
461 }
462
463 pub fn invoking_user(&self) -> Option<&str> {
464 None
465 }
466
467 pub fn is_system_level_enabled(&self) -> Result<bool> {
468 Ok(false)
469 }
470
471 pub fn system_invoking_user(&self) -> Option<String> {
472 None
473 }
474
475 pub fn is_current_level_up_to_date(&self) -> Result<bool> {
476 Ok(false)
477 }
478
479 pub fn disable(&self) -> Result<()> {
480 miette::bail!(
481 "boot management is not supported on this platform; \
482 only macOS, Linux, and Windows are supported"
483 )
484 }
485 }
486}
487
488pub use imp::BootManager;
489
490#[cfg(any(target_os = "linux", all(test, target_os = "macos")))]
493fn systemd_exec_start(unit: &str) -> Option<Vec<String>> {
494 unit.lines()
495 .find_map(|line| line.trim().strip_prefix("ExecStart="))
496 .map(|command| command.split_whitespace().map(String::from).collect())
497}
498
499#[cfg(any(target_os = "macos", all(test, target_os = "linux")))]
501fn launchd_program_arguments(plist: &[u8]) -> Option<Vec<String>> {
502 let value = plist::Value::from_reader(std::io::Cursor::new(plist)).ok()?;
503 value
504 .as_dictionary()?
505 .get("ProgramArguments")?
506 .as_array()?
507 .iter()
508 .map(|arg| arg.as_string().map(String::from))
509 .collect()
510}
511
512#[cfg(all(test, any(target_os = "macos", target_os = "linux")))]
513mod tests {
514 use super::imp::service_args;
515 use super::{launchd_program_arguments, systemd_exec_start};
516 use crate::env::invoking_user_arg;
517
518 fn argv(args: Option<Vec<String>>) -> Vec<std::ffi::OsString> {
519 args.unwrap().into_iter().map(Into::into).collect()
520 }
521
522 #[test]
524 fn invoking_user_is_read_back_from_systemd_unit() {
525 let unit = "[Unit]\nDescription=pitchfork\nAfter=multi-user.target\n\n\
526 [Service]\nType=simple\n\
527 ExecStart=/usr/local/bin/pitchfork supervisor run --boot --invoking-user alice\n\
528 Restart=on-failure\n";
529 let args = systemd_exec_start(unit);
530 assert_eq!(invoking_user_arg(argv(args)).as_deref(), Some("alice"));
531
532 let legacy = "[Service]\nExecStart=/usr/local/bin/pitchfork supervisor run --boot\n";
533 assert_eq!(invoking_user_arg(argv(systemd_exec_start(legacy))), None);
534 assert_eq!(systemd_exec_start("[Service]\n"), None);
535 }
536
537 #[test]
539 fn invoking_user_is_read_back_from_launchd_plist() {
540 let plist = br#"<?xml version="1.0" encoding="UTF-8"?>
541<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
542<plist version="1.0">
543<dict>
544 <key>Label</key>
545 <string>pitchfork</string>
546 <key>ProgramArguments</key>
547 <array>
548 <string>/opt/homebrew/bin/pitchfork</string>
549 <string>supervisor</string>
550 <string>run</string>
551 <string>--boot</string>
552 <string>--invoking-user</string>
553 <string>alice</string>
554 </array>
555 <key>RunAtLoad</key>
556 <true/>
557 <key>SessionCreate</key>
558 <true/>
559</dict>
560</plist>"#;
561 let args = launchd_program_arguments(plist);
562 assert_eq!(invoking_user_arg(argv(args)).as_deref(), Some("alice"));
563 assert_eq!(launchd_program_arguments(b"not a plist"), None);
564 }
565
566 #[test]
567 fn service_args_without_invoking_user_keep_plain_boot_command() {
568 assert_eq!(service_args(None), ["supervisor", "run", "--boot"]);
569 }
570
571 #[test]
572 fn service_args_record_invoking_user() {
573 let args = service_args(Some("alice"));
574 assert_eq!(
575 args,
576 ["supervisor", "run", "--boot", "--invoking-user", "alice"]
577 );
578 let argv = std::iter::once("pitchfork".to_string())
581 .chain(args)
582 .map(std::ffi::OsString::from);
583 assert_eq!(
584 crate::env::invoking_user_arg(argv).as_deref(),
585 Some("alice")
586 );
587 }
588}