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    impl BootManager {
95        /// Manager whose current-level registration records the user this
96        /// process acts on behalf of (see [`env::boot_service_invoking_user`]).
97        pub fn new() -> Result<Self> {
98            #[cfg(any(target_os = "macos", target_os = "linux"))]
99            return Self::with_invoking_user(env::boot_service_invoking_user()?);
100            #[cfg(windows)]
101            Self::with_invoking_user(None)
102        }
103
104        /// Manager whose current-level registration records `invoking_user`.
105        /// Only the current level's registration is ever written, and for root
106        /// that is the system registration.
107        fn with_invoking_user(invoking_user: Option<String>) -> Result<Self> {
108            let configured = crate::settings::settings().boot.executable.clone();
109            let explicit_executable = !configured.is_empty();
110            let app_path = if configured.is_empty() {
111                env::PITCHFORK_BIN.to_string_lossy().to_string()
112            } else {
113                configured
114            };
115
116            #[cfg(any(target_os = "macos", target_os = "linux"))]
117            let (current_args, other_args) =
118                (service_args(invoking_user.as_deref()), service_args(None));
119
120            #[cfg(target_os = "macos")]
121            let (current, other, legacy) = {
122                let is_root = nix::unistd::Uid::effective().is_root();
123                let (current_mode, other_mode) = if is_root {
124                    (
125                        MacOSLaunchMode::LaunchDaemonSystem,
126                        MacOSLaunchMode::LaunchAgentUser,
127                    )
128                } else {
129                    (
130                        MacOSLaunchMode::LaunchAgentUser,
131                        MacOSLaunchMode::LaunchDaemonSystem,
132                    )
133                };
134                (
135                    build_launcher(&app_path, &current_args, current_mode)?,
136                    build_launcher(&app_path, &other_args, other_mode)?,
137                    build_launcher(&app_path, &other_args, MacOSLaunchMode::LaunchAgentSystem)?,
138                )
139            };
140
141            #[cfg(target_os = "linux")]
142            let (current, other) = {
143                let is_root = nix::unistd::Uid::effective().is_root();
144                let (current_mode, other_mode) = if is_root {
145                    (LinuxLaunchMode::SystemdSystem, LinuxLaunchMode::SystemdUser)
146                } else {
147                    (LinuxLaunchMode::SystemdUser, LinuxLaunchMode::SystemdSystem)
148                };
149                (
150                    build_launcher(&app_path, &current_args, current_mode)?,
151                    build_launcher(&app_path, &other_args, other_mode)?,
152                )
153            };
154
155            // On Windows there is no root/user distinction; build two identical
156            // launchers (AutoLaunch does not implement Clone).
157            #[cfg(windows)]
158            let (current, other) = (
159                AutoLaunchBuilder::new()
160                    .set_app_name("pitchfork")
161                    .set_app_path(&app_path)
162                    .set_args(&["supervisor", "run", "--boot"])
163                    .build()
164                    .into_diagnostic()?,
165                AutoLaunchBuilder::new()
166                    .set_app_name("pitchfork")
167                    .set_app_path(&app_path)
168                    .set_args(&["supervisor", "run", "--boot"])
169                    .build()
170                    .into_diagnostic()?,
171            );
172
173            #[cfg(target_os = "macos")]
174            return Ok(Self {
175                app_path,
176                explicit_executable,
177                invoking_user,
178                current,
179                other,
180                legacy,
181            });
182
183            #[cfg(not(target_os = "macos"))]
184            Ok(Self {
185                app_path,
186                explicit_executable,
187                invoking_user,
188                current,
189                other,
190            })
191        }
192
193        /// Validate only explicit choices, and only before registration work.
194        /// Status/disable must remain usable when an executable has disappeared.
195        fn validate_executable(&self) -> Result<()> {
196            if !self.explicit_executable {
197                return Ok(());
198            }
199            let invalid = |reason: &str| {
200                miette::miette!(
201                    "settings.boot.executable '{}': {reason}; set an absolute path to an \
202                     existing executable, or unset the setting to use the running binary",
203                    self.app_path
204                )
205            };
206            let path = std::path::Path::new(&self.app_path);
207            if !path.is_absolute() {
208                return Err(invalid(
209                    "path must be absolute (no PATH or tilde expansion)",
210                ));
211            }
212            if self.app_path.chars().any(char::is_control) {
213                return Err(invalid("path must not contain control characters"));
214            }
215            // auto-launcher writes an unquoted command and reads the first
216            // whitespace-delimited token on these platforms. Reject paths it
217            // cannot round-trip instead of writing a broken registration.
218            #[cfg(any(target_os = "linux", windows))]
219            if self.app_path.chars().any(char::is_whitespace) {
220                return Err(invalid(
221                    "boot registration does not support whitespace in paths on this platform",
222                ));
223            }
224            #[cfg(target_os = "linux")]
225            if self.app_path.contains(['\"', '\'', '\\', '%', '$']) {
226                return Err(invalid(
227                    "boot registration does not support quotes, backslashes, percent signs or dollar signs in systemd executable paths",
228                ));
229            }
230            let metadata = std::fs::metadata(path)
231                .map_err(|e| invalid(&format!("cannot access executable: {e}")))?;
232            if !metadata.is_file() {
233                return Err(invalid("path is not a regular file"));
234            }
235            #[cfg(unix)]
236            {
237                let path = std::ffi::CString::new(self.app_path.as_bytes())
238                    .map_err(|_| invalid("path contains a NUL byte"))?;
239                // SAFETY: path is a valid, NUL-terminated string. access only
240                // checks permissions; it does not run the configured program.
241                if unsafe { libc::access(path.as_ptr(), libc::X_OK) } != 0 {
242                    return Err(invalid("file is not executable by this user"));
243                }
244            }
245            Ok(())
246        }
247
248        /// User recorded in the registration written at the current level.
249        pub fn invoking_user(&self) -> Option<&str> {
250            self.invoking_user.as_deref()
251        }
252
253        /// Whether the system-level registration exists.
254        pub fn is_system_level_enabled(&self) -> Result<bool> {
255            #[cfg(any(target_os = "macos", target_os = "linux"))]
256            let system = if nix::unistd::Uid::effective().is_root() {
257                &self.current
258            } else {
259                &self.other
260            };
261            #[cfg(any(target_os = "macos", target_os = "linux"))]
262            return system.is_enabled().into_diagnostic();
263            #[cfg(windows)]
264            Ok(false)
265        }
266
267        /// The invoking user recorded in the existing system-level
268        /// registration. Readable without root.
269        pub fn system_invoking_user(&self) -> Option<String> {
270            #[cfg(any(target_os = "macos", target_os = "linux"))]
271            return registered_system_invoking_user();
272            #[cfg(windows)]
273            None
274        }
275
276        /// Whether the current-level registration already has the selected
277        /// boot executable and invoking user, so `enable` has nothing to change.
278        pub fn is_current_level_up_to_date(&self) -> Result<bool> {
279            self.validate_executable()?;
280            let registered = self.current.get_registered_app_path().into_diagnostic()?;
281            if registered.as_deref() != Some(self.app_path.as_str()) {
282                return Ok(false);
283            }
284            #[cfg(any(target_os = "macos", target_os = "linux"))]
285            if nix::unistd::Uid::effective().is_root() {
286                return Ok(registered_system_invoking_user() == self.invoking_user);
287            }
288            Ok(true)
289        }
290
291        /// Whether any registration (user- or system-level) exists.
292        pub fn is_enabled(&self) -> Result<bool> {
293            #[cfg(target_os = "macos")]
294            return Ok(self.current.is_enabled().into_diagnostic()?
295                || self.other.is_enabled().into_diagnostic()?
296                || self.legacy.is_enabled().into_diagnostic()?);
297
298            #[cfg(not(target_os = "macos"))]
299            Ok(self.current.is_enabled().into_diagnostic()?
300                || self.other.is_enabled().into_diagnostic()?)
301        }
302
303        /// Whether a registration at the *current* privilege level exists.
304        pub fn is_current_level_enabled(&self) -> Result<bool> {
305            self.current.is_enabled().into_diagnostic()
306        }
307
308        /// Whether a registration at the *other* privilege level exists.
309        /// Used to warn the user about cross-level mismatches.
310        /// On macOS, includes legacy entries for non-root callers (they are at a
311        /// different privilege level) but not for root callers (legacy is same level).
312        pub fn is_other_level_enabled(&self) -> Result<bool> {
313            #[cfg(target_os = "macos")]
314            return Ok(self.other.is_enabled().into_diagnostic()?
315                || (!nix::unistd::Uid::effective().is_root()
316                    && self.legacy.is_enabled().into_diagnostic()?));
317
318            #[cfg(not(target_os = "macos"))]
319            self.other.is_enabled().into_diagnostic()
320        }
321
322        /// Remove legacy macOS LaunchAgentSystem entry if present and caller is root.
323        /// Idempotent — safe to call on every enable path, including retries after
324        /// partial migration (new entry written but legacy removal failed).
325        ///
326        /// `migrated`: true when called after writing a new LaunchDaemonSystem entry
327        /// (full migration); false when just removing a stale leftover.
328        #[cfg(target_os = "macos")]
329        pub fn cleanup_legacy(&self, migrated: bool) -> Result<()> {
330            if nix::unistd::Uid::effective().is_root()
331                && self.legacy.is_enabled().into_diagnostic()?
332            {
333                self.legacy.disable().into_diagnostic()?;
334                if migrated {
335                    info!(
336                        "migrated legacy system-level launch entry from /Library/LaunchAgents/ to /Library/LaunchDaemons/"
337                    );
338                } else {
339                    info!("removed legacy system-level launch entry from /Library/LaunchAgents/");
340                }
341            }
342            Ok(())
343        }
344
345        /// Register at the current privilege level.
346        ///
347        /// Returns an error if a registration at the other privilege level already
348        /// exists, preventing user-level and system-level entries from coexisting.
349        ///
350        /// On macOS, migrates any legacy LaunchAgentSystem entry (from pre-1.0.3)
351        /// to the correct LaunchDaemonSystem entry.
352        pub fn enable(&self) -> Result<()> {
353            self.validate_executable()?;
354            // For root, legacy will be migrated so only check non-legacy other level.
355            // For non-root, legacy cannot be migrated and is also a conflict.
356            #[cfg(target_os = "macos")]
357            let other_conflict = if nix::unistd::Uid::effective().is_root() {
358                self.other.is_enabled().into_diagnostic()?
359            } else {
360                self.is_other_level_enabled()?
361            };
362
363            #[cfg(not(target_os = "macos"))]
364            let other_conflict = self.other.is_enabled().into_diagnostic()?;
365
366            if other_conflict {
367                miette::bail!(
368                    "boot start is already registered at the other privilege level; \
369                    run `pitchfork boot disable` (with appropriate privileges) to remove \
370                    it first"
371                );
372            }
373
374            self.current.enable().into_diagnostic()?;
375
376            #[cfg(target_os = "macos")]
377            self.cleanup_legacy(true)?;
378
379            Ok(())
380        }
381
382        /// Rewrite the existing registration at the current privilege level,
383        /// updating its binary path and recorded invoking user.
384        pub fn refresh(&self) -> Result<()> {
385            self.validate_executable()?;
386            self.current.enable().into_diagnostic()?;
387
388            #[cfg(target_os = "macos")]
389            self.cleanup_legacy(false)?;
390
391            Ok(())
392        }
393
394        /// Remove registrations at *both* levels so cross-level leftovers are also
395        /// cleaned up. Also removes legacy macOS LaunchAgentSystem entries when
396        /// running as root. Returns Ok even if some entries could not be removed
397        /// due to insufficient privileges — callers should check is_enabled()
398        /// afterwards to detect incomplete cleanup.
399        pub fn disable(&self) -> Result<()> {
400            if self.current.is_enabled().into_diagnostic()? {
401                self.current.disable().into_diagnostic()?;
402            }
403            if self.other.is_enabled().into_diagnostic()? {
404                self.other.disable().into_diagnostic()?;
405            }
406            #[cfg(target_os = "macos")]
407            if nix::unistd::Uid::effective().is_root()
408                && self.legacy.is_enabled().into_diagnostic()?
409            {
410                self.legacy.disable().into_diagnostic()?;
411            }
412            Ok(())
413        }
414
415        /// Check whether the registered boot binary path matches the selected
416        /// boot executable. If stale (binary moved after a package-manager upgrade),
417        /// re-register at the current privilege level so the next boot uses the
418        /// correct path.
419        ///
420        /// This is a no-op when boot start is not enabled, or when the registered
421        /// path already matches. Errors are logged and swallowed — this is a
422        /// best-effort self-heal that must not block supervisor startup.
423        pub fn check_and_reregister_if_stale(&self) {
424            if let Err(e) = self.validate_executable() {
425                warn!("cannot repair boot registration: {e}");
426                return;
427            }
428            let current_bin = &self.app_path;
429
430            let registered = match self.current.get_registered_app_path() {
431                Ok(Some(path)) => path,
432                Ok(None) => return, // not registered, nothing to do
433                Err(e) => {
434                    warn!("failed to read registered boot path: {e}");
435                    return;
436                }
437            };
438
439            if registered == *current_bin {
440                return; // path matches, all good
441            }
442
443            info!(
444                "boot registration points to stale binary path '{registered}', \
445                re-registering with current path '{current_bin}'"
446            );
447
448            // Keep the invoking user the registration already records: this
449            // runs in whatever supervisor happens to start first, which must
450            // neither add a user to a root-shell registration nor drop one.
451            #[cfg(any(target_os = "macos", target_os = "linux"))]
452            let preserved = if nix::unistd::Uid::effective().is_root() {
453                let registered = registered_system_invoking_user();
454                if registered == self.invoking_user {
455                    None
456                } else {
457                    match Self::with_invoking_user(registered) {
458                        Ok(manager) => Some(manager),
459                        Err(e) => {
460                            warn!("failed to prepare boot re-registration: {e}");
461                            return;
462                        }
463                    }
464                }
465            } else {
466                None
467            };
468            #[cfg(windows)]
469            let preserved: Option<Self> = None;
470            let launcher = preserved.as_ref().map_or(&self.current, |m| &m.current);
471
472            // Re-register by overwriting the existing registration file.
473            // Calling enable() directly (without disable first) ensures that
474            // if it fails, the stale registration is still present rather than
475            // missing entirely — a stale path is better than no path.
476            if let Err(e) = launcher.enable() {
477                warn!("failed to re-register boot start with current path: {e}");
478                return;
479            }
480
481            info!("boot registration updated to current binary path");
482        }
483    }
484
485    #[cfg(all(test, target_os = "linux"))]
486    mod tests {
487        use super::BootManager;
488        use std::os::unix::fs::{PermissionsExt, symlink};
489
490        #[test]
491        fn refresh_preserves_executable_when_invoking_user_changes() {
492            // Root selects /etc regardless of HOME; never exercise that route.
493            assert!(
494                !nix::unistd::Uid::effective().is_root(),
495                "run this test as non-root"
496            );
497            if std::env::var_os("PITCHFORK_BOOT_METADATA_TEST_CHILD").is_none() {
498                let home = tempfile::tempdir().unwrap();
499                let bin = home.path().join("bin");
500                std::fs::create_dir(&bin).unwrap();
501                let systemctl = bin.join("systemctl");
502                std::fs::write(&systemctl, "#!/bin/sh\ncase \"$*\" in\n'--user daemon-reload'|'--user enable pitchfork.service') exit 0 ;;\n*) exit 99 ;;\nesac\n").unwrap();
503                std::fs::set_permissions(&systemctl, std::fs::Permissions::from_mode(0o755))
504                    .unwrap();
505                symlink(std::env::current_exe().unwrap(), bin.join("stable")).unwrap();
506                // Isolate settings/environment lazies from all other unit tests.
507                let output = std::process::Command::new(std::env::current_exe().unwrap())
508                    .args(["--exact", "boot_manager::imp::tests::refresh_preserves_executable_when_invoking_user_changes", "--nocapture"])
509                    .current_dir(home.path())
510                    .env("HOME", home.path())
511                    .env("PATH", &bin)
512                    .env("PITCHFORK_CONFIG_DIR", home.path())
513                    .env("PITCHFORK_BOOT_EXECUTABLE", bin.join("stable"))
514                    .env("PITCHFORK_BOOT_METADATA_TEST_CHILD", "1")
515                    .output().unwrap();
516                assert!(
517                    output.status.success(),
518                    "{}\n{}",
519                    String::from_utf8_lossy(&output.stdout),
520                    String::from_utf8_lossy(&output.stderr)
521                );
522                return;
523            }
524
525            // Use real BootManager registration with user-level fixture paths.
526            // This covers metadata writing, not root privilege routing or its
527            // automatic existing-account preservation branch.
528            let home = std::path::PathBuf::from(std::env::var_os("HOME").unwrap());
529            let stable = home.join("bin/stable");
530            let unit = home.join(".config/systemd/user/pitchfork.service");
531            for user in [Some("alice"), Some("bob"), None] {
532                let manager = BootManager::with_invoking_user(user.map(String::from)).unwrap();
533                manager.refresh().unwrap();
534                let contents = std::fs::read_to_string(&unit).unwrap();
535                let expected = match user {
536                    Some(user) => format!(
537                        "ExecStart={} supervisor run --boot --invoking-user {user}\n",
538                        stable.display()
539                    ),
540                    None => format!("ExecStart={} supervisor run --boot\n", stable.display()),
541                };
542                assert!(contents.contains(&expected), "{contents}");
543            }
544        }
545    }
546}
547
548// ─── Unsupported platforms ────────────────────────────────────────────────
549
550#[cfg(not(any(target_os = "macos", target_os = "linux", windows)))]
551mod imp {
552    use crate::Result;
553
554    pub struct BootManager;
555
556    impl BootManager {
557        pub fn new() -> Result<Self> {
558            miette::bail!(
559                "boot management is not supported on this platform; \
560                only macOS, Linux, and Windows are supported"
561            )
562        }
563
564        pub fn is_enabled(&self) -> Result<bool> {
565            miette::bail!(
566                "boot management is not supported on this platform; \
567                only macOS, Linux, and Windows are supported"
568            )
569        }
570
571        pub fn is_current_level_enabled(&self) -> Result<bool> {
572            miette::bail!(
573                "boot management is not supported on this platform; \
574                only macOS, Linux, and Windows are supported"
575            )
576        }
577
578        pub fn is_other_level_enabled(&self) -> Result<bool> {
579            miette::bail!(
580                "boot management is not supported on this platform; \
581                only macOS, Linux, and Windows are supported"
582            )
583        }
584
585        pub fn enable(&self) -> Result<()> {
586            miette::bail!(
587                "boot management is not supported on this platform; \
588                only macOS, Linux, and Windows are supported"
589            )
590        }
591
592        pub fn refresh(&self) -> Result<()> {
593            miette::bail!(
594                "boot management is not supported on this platform; \
595                only macOS, Linux, and Windows are supported"
596            )
597        }
598
599        pub fn invoking_user(&self) -> Option<&str> {
600            None
601        }
602
603        pub fn is_system_level_enabled(&self) -> Result<bool> {
604            Ok(false)
605        }
606
607        pub fn system_invoking_user(&self) -> Option<String> {
608            None
609        }
610
611        pub fn is_current_level_up_to_date(&self) -> Result<bool> {
612            Ok(false)
613        }
614
615        pub fn disable(&self) -> Result<()> {
616            miette::bail!(
617                "boot management is not supported on this platform; \
618                only macOS, Linux, and Windows are supported"
619            )
620        }
621    }
622}
623
624pub use imp::BootManager;
625
626/// Command line of a systemd unit's `ExecStart=`, split as the unit was
627/// written: pitchfork's service arguments never need quoting.
628#[cfg(any(target_os = "linux", all(test, target_os = "macos")))]
629fn systemd_exec_start(unit: &str) -> Option<Vec<String>> {
630    unit.lines()
631        .find_map(|line| line.trim().strip_prefix("ExecStart="))
632        .map(|command| command.split_whitespace().map(String::from).collect())
633}
634
635/// `ProgramArguments` of a launchd plist.
636#[cfg(any(target_os = "macos", all(test, target_os = "linux")))]
637fn launchd_program_arguments(plist: &[u8]) -> Option<Vec<String>> {
638    let value = plist::Value::from_reader(std::io::Cursor::new(plist)).ok()?;
639    value
640        .as_dictionary()?
641        .get("ProgramArguments")?
642        .as_array()?
643        .iter()
644        .map(|arg| arg.as_string().map(String::from))
645        .collect()
646}
647
648#[cfg(all(test, any(target_os = "macos", target_os = "linux")))]
649mod tests {
650    use super::imp::service_args;
651    use super::{launchd_program_arguments, systemd_exec_start};
652    use crate::env::invoking_user_arg;
653
654    fn argv(args: Option<Vec<String>>) -> Vec<std::ffi::OsString> {
655        args.unwrap().into_iter().map(Into::into).collect()
656    }
657
658    /// The system unit as `sudo pitchfork boot enable` writes it on Linux.
659    #[test]
660    fn invoking_user_is_read_back_from_systemd_unit() {
661        let unit = "[Unit]\nDescription=pitchfork\nAfter=multi-user.target\n\n\
662            [Service]\nType=simple\n\
663            ExecStart=/usr/local/bin/pitchfork supervisor run --boot --invoking-user alice\n\
664            Restart=on-failure\n";
665        let args = systemd_exec_start(unit);
666        assert_eq!(invoking_user_arg(argv(args)).as_deref(), Some("alice"));
667
668        let legacy = "[Service]\nExecStart=/usr/local/bin/pitchfork supervisor run --boot\n";
669        assert_eq!(invoking_user_arg(argv(systemd_exec_start(legacy))), None);
670        assert_eq!(systemd_exec_start("[Service]\n"), None);
671    }
672
673    /// The LaunchDaemon as `sudo pitchfork boot enable` writes it on macOS.
674    #[test]
675    fn invoking_user_is_read_back_from_launchd_plist() {
676        let plist = br#"<?xml version="1.0" encoding="UTF-8"?>
677<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
678<plist version="1.0">
679<dict>
680	<key>Label</key>
681	<string>pitchfork</string>
682	<key>ProgramArguments</key>
683	<array>
684		<string>/opt/homebrew/bin/pitchfork</string>
685		<string>supervisor</string>
686		<string>run</string>
687		<string>--boot</string>
688		<string>--invoking-user</string>
689		<string>alice</string>
690	</array>
691	<key>RunAtLoad</key>
692	<true/>
693	<key>SessionCreate</key>
694	<true/>
695</dict>
696</plist>"#;
697        let args = launchd_program_arguments(plist);
698        assert_eq!(invoking_user_arg(argv(args)).as_deref(), Some("alice"));
699        assert_eq!(launchd_program_arguments(b"not a plist"), None);
700    }
701
702    #[test]
703    fn service_args_without_invoking_user_keep_plain_boot_command() {
704        assert_eq!(service_args(None), ["supervisor", "run", "--boot"]);
705    }
706
707    #[test]
708    fn service_args_record_invoking_user() {
709        let args = service_args(Some("alice"));
710        assert_eq!(
711            args,
712            ["supervisor", "run", "--boot", "--invoking-user", "alice"]
713        );
714        // The service command line must yield the same user when the
715        // supervisor resolves paths from argv at startup.
716        let argv = std::iter::once("pitchfork".to_string())
717            .chain(args)
718            .map(std::ffi::OsString::from);
719        assert_eq!(
720            crate::env::invoking_user_arg(argv).as_deref(),
721            Some("alice")
722        );
723    }
724}