Skip to main content

pitchfork_cli/
boot_manager.rs

1// ─── Supported platforms (macOS, Linux, Windows) ──────────────────────────
2
3#[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    /// Arguments of the registered `pitchfork` command.
14    ///
15    /// A system registration made through sudo records the invoking user, since
16    /// launchd and systemd start the service without the sudo environment.
17    #[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        /// User recorded in the system registration, if any.
54        invoking_user: Option<String>,
55        /// The launcher matching the current privilege level (used for enable).
56        current: AutoLaunch,
57        /// The other level's launcher (used to detect cross-level registrations).
58        other: AutoLaunch,
59        /// Legacy macOS LaunchAgentSystem entry (pre-1.0.3 used /Library/LaunchAgents/
60        /// instead of /Library/LaunchDaemons/ for root). Kept only for migration/cleanup.
61        #[cfg(target_os = "macos")]
62        legacy: AutoLaunch,
63    }
64
65    /// Where the system-level registration lives.
66    #[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    /// The invoking user recorded in the system-level registration, if the
72    /// registration exists, can be read, and records one.
73    #[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        /// Manager whose current-level registration records the user this
93        /// process acts on behalf of (see [`env::boot_service_invoking_user`]).
94        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        /// Manager whose current-level registration records `invoking_user`.
102        /// Only the current level's registration is ever written, and for root
103        /// that is the system registration.
104        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, &current_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, &current_args, current_mode)?,
142                    build_launcher(&app_path, &other_args, other_mode)?,
143                )
144            };
145
146            // On Windows there is no root/user distinction; build two identical
147            // launchers (AutoLaunch does not implement Clone).
148            #[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        /// User recorded in the registration written at the current level.
181        pub fn invoking_user(&self) -> Option<&str> {
182            self.invoking_user.as_deref()
183        }
184
185        /// Whether the system-level registration exists.
186        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        /// The invoking user recorded in the existing system-level
200        /// registration. Readable without root.
201        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        /// Whether the current-level registration already has the current
209        /// binary path and invoking user, so `enable` has nothing to change.
210        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        /// Whether any registration (user- or system-level) exists.
224        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        /// Whether a registration at the *current* privilege level exists.
236        pub fn is_current_level_enabled(&self) -> Result<bool> {
237            self.current.is_enabled().into_diagnostic()
238        }
239
240        /// Whether a registration at the *other* privilege level exists.
241        /// Used to warn the user about cross-level mismatches.
242        /// On macOS, includes legacy entries for non-root callers (they are at a
243        /// different privilege level) but not for root callers (legacy is same level).
244        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        /// Remove legacy macOS LaunchAgentSystem entry if present and caller is root.
255        /// Idempotent — safe to call on every enable path, including retries after
256        /// partial migration (new entry written but legacy removal failed).
257        ///
258        /// `migrated`: true when called after writing a new LaunchDaemonSystem entry
259        /// (full migration); false when just removing a stale leftover.
260        #[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        /// Register at the current privilege level.
278        ///
279        /// Returns an error if a registration at the other privilege level already
280        /// exists, preventing user-level and system-level entries from coexisting.
281        ///
282        /// On macOS, migrates any legacy LaunchAgentSystem entry (from pre-1.0.3)
283        /// to the correct LaunchDaemonSystem entry.
284        pub fn enable(&self) -> Result<()> {
285            // For root, legacy will be migrated so only check non-legacy other level.
286            // For non-root, legacy cannot be migrated and is also a conflict.
287            #[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        /// Rewrite the existing registration at the current privilege level,
314        /// updating its binary path and recorded invoking user.
315        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        /// Remove registrations at *both* levels so cross-level leftovers are also
325        /// cleaned up. Also removes legacy macOS LaunchAgentSystem entries when
326        /// running as root. Returns Ok even if some entries could not be removed
327        /// due to insufficient privileges — callers should check is_enabled()
328        /// afterwards to detect incomplete cleanup.
329        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        /// Check whether the registered boot binary path matches the current
346        /// `PITCHFORK_BIN`. If stale (binary moved after a package-manager upgrade),
347        /// re-register at the current privilege level so the next boot uses the
348        /// correct path.
349        ///
350        /// This is a no-op when boot start is not enabled, or when the registered
351        /// path already matches. Errors are logged and swallowed — this is a
352        /// best-effort self-heal that must not block supervisor startup.
353        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, // not registered, nothing to do
359                Err(e) => {
360                    warn!("failed to read registered boot path: {e}");
361                    return;
362                }
363            };
364
365            if registered == current_bin {
366                return; // path matches, all good
367            }
368
369            info!(
370                "boot registration points to stale binary path '{registered}', \
371                re-registering with current path '{current_bin}'"
372            );
373
374            // Keep the invoking user the registration already records: this
375            // runs in whatever supervisor happens to start first, which must
376            // neither add a user to a root-shell registration nor drop one.
377            #[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            // Re-register by overwriting the existing registration file.
399            // Calling enable() directly (without disable first) ensures that
400            // if it fails, the stale registration is still present rather than
401            // missing entirely — a stale path is better than no path.
402            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// ─── Unsupported platforms ────────────────────────────────────────────────
413
414#[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/// Command line of a systemd unit's `ExecStart=`, split as the unit was
491/// written: pitchfork's service arguments never need quoting.
492#[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/// `ProgramArguments` of a launchd plist.
500#[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    /// The system unit as `sudo pitchfork boot enable` writes it on Linux.
523    #[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    /// The LaunchDaemon as `sudo pitchfork boot enable` writes it on macOS.
538    #[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        // The service command line must yield the same user when the
579        // supervisor resolves paths from argv at startup.
580        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}