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        /// Literal executable spelling selected for boot registration only.
54        app_path: String,
55        explicit_executable: bool,
56        /// User recorded in the system registration, if any.
57        invoking_user: Option<String>,
58        /// The launcher matching the current privilege level (used for enable).
59        current: AutoLaunch,
60        /// The other level's launcher (used to detect cross-level registrations).
61        other: AutoLaunch,
62        /// Legacy macOS LaunchAgentSystem entry (pre-1.0.3 used /Library/LaunchAgents/
63        /// instead of /Library/LaunchDaemons/ for root). Kept only for migration/cleanup.
64        #[cfg(target_os = "macos")]
65        legacy: AutoLaunch,
66    }
67
68    /// Where the system-level registration lives.
69    #[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    /// The invoking user recorded in the system-level registration, if the
75    /// registration exists, can be read, and records one.
76    #[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    /// Write the launchd registration, then add the crash-restart keys that
95    /// `auto-launcher` does not emit (the macOS counterpart of systemd's
96    /// `Restart=on-failure`).
97    fn write_registration(launcher: &AutoLaunch) -> Result<()> {
98        // `enable` writes the plist anew, so a restart policy the user set on
99        // the registration is read first and put back afterwards.
100        #[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    /// The launchd plist `auto-launcher` writes at the current privilege level.
118    #[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        /// Manager whose current-level registration records the user this
150        /// process acts on behalf of (see [`env::boot_service_invoking_user`]).
151        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        /// Manager whose current-level registration records `invoking_user`.
159        /// Only the current level's registration is ever written, and for root
160        /// that is the system registration.
161        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, &current_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, &current_args, current_mode)?,
205                    build_launcher(&app_path, &other_args, other_mode)?,
206                )
207            };
208
209            // On Windows there is no root/user distinction; build two identical
210            // launchers (AutoLaunch does not implement Clone).
211            #[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        /// Validate only explicit choices, and only before registration work.
248        /// Status/disable must remain usable when an executable has disappeared.
249        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            // auto-launcher writes an unquoted command and reads the first
270            // whitespace-delimited token on these platforms. Reject paths it
271            // cannot round-trip instead of writing a broken registration.
272            #[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                // SAFETY: path is a valid, NUL-terminated string. access only
294                // checks permissions; it does not run the configured program.
295                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        /// User recorded in the registration written at the current level.
303        pub fn invoking_user(&self) -> Option<&str> {
304            self.invoking_user.as_deref()
305        }
306
307        /// Whether the system-level registration exists.
308        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        /// The invoking user recorded in the existing system-level
322        /// registration. Readable without root.
323        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        /// Whether the current-level registration already has the selected
331        /// boot executable and invoking user, so `enable` has nothing to change.
332        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            // Registrations written before crash-restart was added lack it.
339            #[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        /// Whether any registration (user- or system-level) exists.
354        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        /// Whether a registration at the *current* privilege level exists.
366        pub fn is_current_level_enabled(&self) -> Result<bool> {
367            self.current.is_enabled().into_diagnostic()
368        }
369
370        /// Whether a registration at the *other* privilege level exists.
371        /// Used to warn the user about cross-level mismatches.
372        /// On macOS, includes legacy entries for non-root callers (they are at a
373        /// different privilege level) but not for root callers (legacy is same level).
374        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        /// Remove legacy macOS LaunchAgentSystem entry if present and caller is root.
385        /// Idempotent — safe to call on every enable path, including retries after
386        /// partial migration (new entry written but legacy removal failed).
387        ///
388        /// `migrated`: true when called after writing a new LaunchDaemonSystem entry
389        /// (full migration); false when just removing a stale leftover.
390        #[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        /// Register at the current privilege level.
408        ///
409        /// Returns an error if a registration at the other privilege level already
410        /// exists, preventing user-level and system-level entries from coexisting.
411        ///
412        /// On macOS, migrates any legacy LaunchAgentSystem entry (from pre-1.0.3)
413        /// to the correct LaunchDaemonSystem entry.
414        pub fn enable(&self) -> Result<()> {
415            self.validate_executable()?;
416            // For root, legacy will be migrated so only check non-legacy other level.
417            // For non-root, legacy cannot be migrated and is also a conflict.
418            #[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        /// Rewrite the existing registration at the current privilege level,
445        /// updating its binary path and recorded invoking user.
446        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        /// Remove registrations at *both* levels so cross-level leftovers are also
457        /// cleaned up. Also removes legacy macOS LaunchAgentSystem entries when
458        /// running as root. Returns Ok even if some entries could not be removed
459        /// due to insufficient privileges — callers should check is_enabled()
460        /// afterwards to detect incomplete cleanup.
461        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        /// Check whether the registered boot binary path matches the selected
478        /// boot executable. If stale (binary moved after a package-manager upgrade),
479        /// re-register at the current privilege level so the next boot uses the
480        /// correct path.
481        ///
482        /// This is a no-op when boot start is not enabled, or when the registered
483        /// path already matches. Errors are logged and swallowed — this is a
484        /// best-effort self-heal that must not block supervisor startup.
485        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, // not registered, nothing to do
495                Err(e) => {
496                    warn!("failed to read registered boot path: {e}");
497                    return;
498                }
499            };
500
501            if registered == *current_bin {
502                return; // path matches, all good
503            }
504
505            info!(
506                "boot registration points to stale binary path '{registered}', \
507                re-registering with current path '{current_bin}'"
508            );
509
510            // Keep the invoking user the registration already records: this
511            // runs in whatever supervisor happens to start first, which must
512            // neither add a user to a root-shell registration nor drop one.
513            #[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            // Re-register by overwriting the existing registration file.
535            // Calling enable() directly (without disable first) ensures that
536            // if it fails, the stale registration is still present rather than
537            // missing entirely — a stale path is better than no path.
538            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            // Root selects /etc regardless of HOME; never exercise that route.
555            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                // Isolate settings/environment lazies from all other unit tests.
569                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            // Use real BootManager registration with user-level fixture paths.
588            // This covers metadata writing, not root privilege routing or its
589            // automatic existing-account preservation branch.
590            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// ─── Unsupported platforms ────────────────────────────────────────────────
611
612#[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/// Command line of a systemd unit's `ExecStart=`, split as the unit was
689/// written: pitchfork's service arguments never need quoting.
690#[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/// `ProgramArguments` of a launchd plist.
698#[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/// The launchd keys that make up a registration's restart policy.
711#[cfg(any(target_os = "macos", all(test, target_os = "linux")))]
712const LAUNCHD_RESTART_KEYS: [&str; 2] = ["KeepAlive", "ThrottleInterval"];
713
714/// `plist` with the restart policy (`KeepAlive`, `ThrottleInterval`) of
715/// `previous`, the registration it replaces, or `None` when `previous` has
716/// none to carry over (or either cannot be parsed). Rewriting a registration,
717/// as repairing a stale executable path does, must not drop a policy the user
718/// set on it.
719#[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/// `plist` with launchd's crash-restart keys added, or `None` when it already
743/// has a `KeepAlive` (or cannot be parsed). `SuccessfulExit = false` restarts
744/// the supervisor after a crash or SIGKILL but not after a clean exit, so
745/// `pitchfork supervisor stop` and `launchctl bootout` still stay stopped. The
746/// 10s throttle matches the systemd unit's `RestartSec=10`.
747///
748/// Only a plist with no `KeepAlive` at all gets them: one written before
749/// crash-restart was added. A `KeepAlive` of any other value, like a
750/// `ThrottleInterval` already there, is local launchd policy the user set on
751/// the registration, and is kept.
752#[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    /// The system unit as `sudo pitchfork boot enable` writes it on Linux.
784    #[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    /// The LaunchDaemon as `sudo pitchfork boot enable` writes it on macOS.
799    #[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        // The service command line must yield the same user when the
840        // supervisor resolves paths from argv at startup.
841        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    /// A rewritten registration, as `auto-launcher` writes it, takes back
879    /// the restart policy of the one it replaces, and then needs no default.
880    #[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        // A previous registration with no policy leaves the defaults to come.
913        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    /// A `KeepAlive` the user set on the registration is their launchd
924    /// policy, not a registration from before crash-restart was added.
925    #[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        // Without a KeepAlive, a ThrottleInterval already there is kept too.
939        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}