#[cfg(any(target_os = "macos", target_os = "linux", windows))]
mod imp {
use crate::{Result, env};
#[cfg(target_os = "linux")]
use auto_launcher::LinuxLaunchMode;
#[cfg(target_os = "macos")]
use auto_launcher::MacOSLaunchMode;
use auto_launcher::{AutoLaunch, AutoLaunchBuilder};
use miette::IntoDiagnostic;
#[cfg(any(target_os = "macos", target_os = "linux"))]
pub(crate) fn service_args(invoking_user: Option<&str>) -> Vec<String> {
let mut args: Vec<String> = ["supervisor", "run", "--boot"]
.into_iter()
.map(String::from)
.collect();
if let Some(user) = invoking_user {
args.push(env::INVOKING_USER_FLAG.to_string());
args.push(user.to_string());
}
args
}
#[cfg(any(target_os = "macos", target_os = "linux"))]
fn build_launcher(
app_path: &str,
args: &[String],
#[cfg(target_os = "macos")] macos_mode: MacOSLaunchMode,
#[cfg(target_os = "linux")] linux_mode: LinuxLaunchMode,
) -> Result<AutoLaunch> {
let mut builder = AutoLaunchBuilder::new();
builder
.set_app_name("pitchfork")
.set_app_path(app_path)
.set_args(args);
#[cfg(target_os = "macos")]
builder.set_macos_launch_mode(macos_mode);
#[cfg(target_os = "linux")]
builder.set_linux_launch_mode(linux_mode);
builder.build().into_diagnostic()
}
pub struct BootManager {
app_path: String,
explicit_executable: bool,
invoking_user: Option<String>,
current: AutoLaunch,
other: AutoLaunch,
#[cfg(target_os = "macos")]
legacy: AutoLaunch,
}
#[cfg(target_os = "macos")]
const SYSTEM_REGISTRATION: &str = "/Library/LaunchDaemons/pitchfork.plist";
#[cfg(target_os = "linux")]
const SYSTEM_REGISTRATION: &str = "/etc/systemd/system/pitchfork.service";
#[cfg(any(target_os = "macos", target_os = "linux"))]
fn registered_system_invoking_user() -> Option<String> {
let contents = match std::fs::read(SYSTEM_REGISTRATION) {
Ok(contents) => contents,
Err(err) => {
if err.kind() != std::io::ErrorKind::NotFound {
warn!("failed to read {SYSTEM_REGISTRATION}: {err}");
}
return None;
}
};
#[cfg(target_os = "macos")]
let argv = super::launchd_program_arguments(&contents);
#[cfg(target_os = "linux")]
let argv = super::systemd_exec_start(&String::from_utf8_lossy(&contents));
env::invoking_user_arg(argv?.into_iter().map(Into::into))
}
impl BootManager {
pub fn new() -> Result<Self> {
#[cfg(any(target_os = "macos", target_os = "linux"))]
return Self::with_invoking_user(env::boot_service_invoking_user()?);
#[cfg(windows)]
Self::with_invoking_user(None)
}
fn with_invoking_user(invoking_user: Option<String>) -> Result<Self> {
let configured = crate::settings::settings().boot.executable.clone();
let explicit_executable = !configured.is_empty();
let app_path = if configured.is_empty() {
env::PITCHFORK_BIN.to_string_lossy().to_string()
} else {
configured
};
#[cfg(any(target_os = "macos", target_os = "linux"))]
let (current_args, other_args) =
(service_args(invoking_user.as_deref()), service_args(None));
#[cfg(target_os = "macos")]
let (current, other, legacy) = {
let is_root = nix::unistd::Uid::effective().is_root();
let (current_mode, other_mode) = if is_root {
(
MacOSLaunchMode::LaunchDaemonSystem,
MacOSLaunchMode::LaunchAgentUser,
)
} else {
(
MacOSLaunchMode::LaunchAgentUser,
MacOSLaunchMode::LaunchDaemonSystem,
)
};
(
build_launcher(&app_path, ¤t_args, current_mode)?,
build_launcher(&app_path, &other_args, other_mode)?,
build_launcher(&app_path, &other_args, MacOSLaunchMode::LaunchAgentSystem)?,
)
};
#[cfg(target_os = "linux")]
let (current, other) = {
let is_root = nix::unistd::Uid::effective().is_root();
let (current_mode, other_mode) = if is_root {
(LinuxLaunchMode::SystemdSystem, LinuxLaunchMode::SystemdUser)
} else {
(LinuxLaunchMode::SystemdUser, LinuxLaunchMode::SystemdSystem)
};
(
build_launcher(&app_path, ¤t_args, current_mode)?,
build_launcher(&app_path, &other_args, other_mode)?,
)
};
#[cfg(windows)]
let (current, other) = (
AutoLaunchBuilder::new()
.set_app_name("pitchfork")
.set_app_path(&app_path)
.set_args(&["supervisor", "run", "--boot"])
.build()
.into_diagnostic()?,
AutoLaunchBuilder::new()
.set_app_name("pitchfork")
.set_app_path(&app_path)
.set_args(&["supervisor", "run", "--boot"])
.build()
.into_diagnostic()?,
);
#[cfg(target_os = "macos")]
return Ok(Self {
app_path,
explicit_executable,
invoking_user,
current,
other,
legacy,
});
#[cfg(not(target_os = "macos"))]
Ok(Self {
app_path,
explicit_executable,
invoking_user,
current,
other,
})
}
fn validate_executable(&self) -> Result<()> {
if !self.explicit_executable {
return Ok(());
}
let invalid = |reason: &str| {
miette::miette!(
"settings.boot.executable '{}': {reason}; set an absolute path to an \
existing executable, or unset the setting to use the running binary",
self.app_path
)
};
let path = std::path::Path::new(&self.app_path);
if !path.is_absolute() {
return Err(invalid(
"path must be absolute (no PATH or tilde expansion)",
));
}
if self.app_path.chars().any(char::is_control) {
return Err(invalid("path must not contain control characters"));
}
#[cfg(any(target_os = "linux", windows))]
if self.app_path.chars().any(char::is_whitespace) {
return Err(invalid(
"boot registration does not support whitespace in paths on this platform",
));
}
#[cfg(target_os = "linux")]
if self.app_path.contains(['\"', '\'', '\\', '%', '$']) {
return Err(invalid(
"boot registration does not support quotes, backslashes, percent signs or dollar signs in systemd executable paths",
));
}
let metadata = std::fs::metadata(path)
.map_err(|e| invalid(&format!("cannot access executable: {e}")))?;
if !metadata.is_file() {
return Err(invalid("path is not a regular file"));
}
#[cfg(unix)]
{
let path = std::ffi::CString::new(self.app_path.as_bytes())
.map_err(|_| invalid("path contains a NUL byte"))?;
if unsafe { libc::access(path.as_ptr(), libc::X_OK) } != 0 {
return Err(invalid("file is not executable by this user"));
}
}
Ok(())
}
pub fn invoking_user(&self) -> Option<&str> {
self.invoking_user.as_deref()
}
pub fn is_system_level_enabled(&self) -> Result<bool> {
#[cfg(any(target_os = "macos", target_os = "linux"))]
let system = if nix::unistd::Uid::effective().is_root() {
&self.current
} else {
&self.other
};
#[cfg(any(target_os = "macos", target_os = "linux"))]
return system.is_enabled().into_diagnostic();
#[cfg(windows)]
Ok(false)
}
pub fn system_invoking_user(&self) -> Option<String> {
#[cfg(any(target_os = "macos", target_os = "linux"))]
return registered_system_invoking_user();
#[cfg(windows)]
None
}
pub fn is_current_level_up_to_date(&self) -> Result<bool> {
self.validate_executable()?;
let registered = self.current.get_registered_app_path().into_diagnostic()?;
if registered.as_deref() != Some(self.app_path.as_str()) {
return Ok(false);
}
#[cfg(any(target_os = "macos", target_os = "linux"))]
if nix::unistd::Uid::effective().is_root() {
return Ok(registered_system_invoking_user() == self.invoking_user);
}
Ok(true)
}
pub fn is_enabled(&self) -> Result<bool> {
#[cfg(target_os = "macos")]
return Ok(self.current.is_enabled().into_diagnostic()?
|| self.other.is_enabled().into_diagnostic()?
|| self.legacy.is_enabled().into_diagnostic()?);
#[cfg(not(target_os = "macos"))]
Ok(self.current.is_enabled().into_diagnostic()?
|| self.other.is_enabled().into_diagnostic()?)
}
pub fn is_current_level_enabled(&self) -> Result<bool> {
self.current.is_enabled().into_diagnostic()
}
pub fn is_other_level_enabled(&self) -> Result<bool> {
#[cfg(target_os = "macos")]
return Ok(self.other.is_enabled().into_diagnostic()?
|| (!nix::unistd::Uid::effective().is_root()
&& self.legacy.is_enabled().into_diagnostic()?));
#[cfg(not(target_os = "macos"))]
self.other.is_enabled().into_diagnostic()
}
#[cfg(target_os = "macos")]
pub fn cleanup_legacy(&self, migrated: bool) -> Result<()> {
if nix::unistd::Uid::effective().is_root()
&& self.legacy.is_enabled().into_diagnostic()?
{
self.legacy.disable().into_diagnostic()?;
if migrated {
info!(
"migrated legacy system-level launch entry from /Library/LaunchAgents/ to /Library/LaunchDaemons/"
);
} else {
info!("removed legacy system-level launch entry from /Library/LaunchAgents/");
}
}
Ok(())
}
pub fn enable(&self) -> Result<()> {
self.validate_executable()?;
#[cfg(target_os = "macos")]
let other_conflict = if nix::unistd::Uid::effective().is_root() {
self.other.is_enabled().into_diagnostic()?
} else {
self.is_other_level_enabled()?
};
#[cfg(not(target_os = "macos"))]
let other_conflict = self.other.is_enabled().into_diagnostic()?;
if other_conflict {
miette::bail!(
"boot start is already registered at the other privilege level; \
run `pitchfork boot disable` (with appropriate privileges) to remove \
it first"
);
}
self.current.enable().into_diagnostic()?;
#[cfg(target_os = "macos")]
self.cleanup_legacy(true)?;
Ok(())
}
pub fn refresh(&self) -> Result<()> {
self.validate_executable()?;
self.current.enable().into_diagnostic()?;
#[cfg(target_os = "macos")]
self.cleanup_legacy(false)?;
Ok(())
}
pub fn disable(&self) -> Result<()> {
if self.current.is_enabled().into_diagnostic()? {
self.current.disable().into_diagnostic()?;
}
if self.other.is_enabled().into_diagnostic()? {
self.other.disable().into_diagnostic()?;
}
#[cfg(target_os = "macos")]
if nix::unistd::Uid::effective().is_root()
&& self.legacy.is_enabled().into_diagnostic()?
{
self.legacy.disable().into_diagnostic()?;
}
Ok(())
}
pub fn check_and_reregister_if_stale(&self) {
if let Err(e) = self.validate_executable() {
warn!("cannot repair boot registration: {e}");
return;
}
let current_bin = &self.app_path;
let registered = match self.current.get_registered_app_path() {
Ok(Some(path)) => path,
Ok(None) => return, Err(e) => {
warn!("failed to read registered boot path: {e}");
return;
}
};
if registered == *current_bin {
return; }
info!(
"boot registration points to stale binary path '{registered}', \
re-registering with current path '{current_bin}'"
);
#[cfg(any(target_os = "macos", target_os = "linux"))]
let preserved = if nix::unistd::Uid::effective().is_root() {
let registered = registered_system_invoking_user();
if registered == self.invoking_user {
None
} else {
match Self::with_invoking_user(registered) {
Ok(manager) => Some(manager),
Err(e) => {
warn!("failed to prepare boot re-registration: {e}");
return;
}
}
}
} else {
None
};
#[cfg(windows)]
let preserved: Option<Self> = None;
let launcher = preserved.as_ref().map_or(&self.current, |m| &m.current);
if let Err(e) = launcher.enable() {
warn!("failed to re-register boot start with current path: {e}");
return;
}
info!("boot registration updated to current binary path");
}
}
#[cfg(all(test, target_os = "linux"))]
mod tests {
use super::BootManager;
use std::os::unix::fs::{PermissionsExt, symlink};
#[test]
fn refresh_preserves_executable_when_invoking_user_changes() {
assert!(
!nix::unistd::Uid::effective().is_root(),
"run this test as non-root"
);
if std::env::var_os("PITCHFORK_BOOT_METADATA_TEST_CHILD").is_none() {
let home = tempfile::tempdir().unwrap();
let bin = home.path().join("bin");
std::fs::create_dir(&bin).unwrap();
let systemctl = bin.join("systemctl");
std::fs::write(&systemctl, "#!/bin/sh\ncase \"$*\" in\n'--user daemon-reload'|'--user enable pitchfork.service') exit 0 ;;\n*) exit 99 ;;\nesac\n").unwrap();
std::fs::set_permissions(&systemctl, std::fs::Permissions::from_mode(0o755))
.unwrap();
symlink(std::env::current_exe().unwrap(), bin.join("stable")).unwrap();
let output = std::process::Command::new(std::env::current_exe().unwrap())
.args(["--exact", "boot_manager::imp::tests::refresh_preserves_executable_when_invoking_user_changes", "--nocapture"])
.current_dir(home.path())
.env("HOME", home.path())
.env("PATH", &bin)
.env("PITCHFORK_CONFIG_DIR", home.path())
.env("PITCHFORK_BOOT_EXECUTABLE", bin.join("stable"))
.env("PITCHFORK_BOOT_METADATA_TEST_CHILD", "1")
.output().unwrap();
assert!(
output.status.success(),
"{}\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
return;
}
let home = std::path::PathBuf::from(std::env::var_os("HOME").unwrap());
let stable = home.join("bin/stable");
let unit = home.join(".config/systemd/user/pitchfork.service");
for user in [Some("alice"), Some("bob"), None] {
let manager = BootManager::with_invoking_user(user.map(String::from)).unwrap();
manager.refresh().unwrap();
let contents = std::fs::read_to_string(&unit).unwrap();
let expected = match user {
Some(user) => format!(
"ExecStart={} supervisor run --boot --invoking-user {user}\n",
stable.display()
),
None => format!("ExecStart={} supervisor run --boot\n", stable.display()),
};
assert!(contents.contains(&expected), "{contents}");
}
}
}
}
#[cfg(not(any(target_os = "macos", target_os = "linux", windows)))]
mod imp {
use crate::Result;
pub struct BootManager;
impl BootManager {
pub fn new() -> Result<Self> {
miette::bail!(
"boot management is not supported on this platform; \
only macOS, Linux, and Windows are supported"
)
}
pub fn is_enabled(&self) -> Result<bool> {
miette::bail!(
"boot management is not supported on this platform; \
only macOS, Linux, and Windows are supported"
)
}
pub fn is_current_level_enabled(&self) -> Result<bool> {
miette::bail!(
"boot management is not supported on this platform; \
only macOS, Linux, and Windows are supported"
)
}
pub fn is_other_level_enabled(&self) -> Result<bool> {
miette::bail!(
"boot management is not supported on this platform; \
only macOS, Linux, and Windows are supported"
)
}
pub fn enable(&self) -> Result<()> {
miette::bail!(
"boot management is not supported on this platform; \
only macOS, Linux, and Windows are supported"
)
}
pub fn refresh(&self) -> Result<()> {
miette::bail!(
"boot management is not supported on this platform; \
only macOS, Linux, and Windows are supported"
)
}
pub fn invoking_user(&self) -> Option<&str> {
None
}
pub fn is_system_level_enabled(&self) -> Result<bool> {
Ok(false)
}
pub fn system_invoking_user(&self) -> Option<String> {
None
}
pub fn is_current_level_up_to_date(&self) -> Result<bool> {
Ok(false)
}
pub fn disable(&self) -> Result<()> {
miette::bail!(
"boot management is not supported on this platform; \
only macOS, Linux, and Windows are supported"
)
}
}
}
pub use imp::BootManager;
#[cfg(any(target_os = "linux", all(test, target_os = "macos")))]
fn systemd_exec_start(unit: &str) -> Option<Vec<String>> {
unit.lines()
.find_map(|line| line.trim().strip_prefix("ExecStart="))
.map(|command| command.split_whitespace().map(String::from).collect())
}
#[cfg(any(target_os = "macos", all(test, target_os = "linux")))]
fn launchd_program_arguments(plist: &[u8]) -> Option<Vec<String>> {
let value = plist::Value::from_reader(std::io::Cursor::new(plist)).ok()?;
value
.as_dictionary()?
.get("ProgramArguments")?
.as_array()?
.iter()
.map(|arg| arg.as_string().map(String::from))
.collect()
}
#[cfg(all(test, any(target_os = "macos", target_os = "linux")))]
mod tests {
use super::imp::service_args;
use super::{launchd_program_arguments, systemd_exec_start};
use crate::env::invoking_user_arg;
fn argv(args: Option<Vec<String>>) -> Vec<std::ffi::OsString> {
args.unwrap().into_iter().map(Into::into).collect()
}
#[test]
fn invoking_user_is_read_back_from_systemd_unit() {
let unit = "[Unit]\nDescription=pitchfork\nAfter=multi-user.target\n\n\
[Service]\nType=simple\n\
ExecStart=/usr/local/bin/pitchfork supervisor run --boot --invoking-user alice\n\
Restart=on-failure\n";
let args = systemd_exec_start(unit);
assert_eq!(invoking_user_arg(argv(args)).as_deref(), Some("alice"));
let legacy = "[Service]\nExecStart=/usr/local/bin/pitchfork supervisor run --boot\n";
assert_eq!(invoking_user_arg(argv(systemd_exec_start(legacy))), None);
assert_eq!(systemd_exec_start("[Service]\n"), None);
}
#[test]
fn invoking_user_is_read_back_from_launchd_plist() {
let plist = br#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>pitchfork</string>
<key>ProgramArguments</key>
<array>
<string>/opt/homebrew/bin/pitchfork</string>
<string>supervisor</string>
<string>run</string>
<string>--boot</string>
<string>--invoking-user</string>
<string>alice</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>SessionCreate</key>
<true/>
</dict>
</plist>"#;
let args = launchd_program_arguments(plist);
assert_eq!(invoking_user_arg(argv(args)).as_deref(), Some("alice"));
assert_eq!(launchd_program_arguments(b"not a plist"), None);
}
#[test]
fn service_args_without_invoking_user_keep_plain_boot_command() {
assert_eq!(service_args(None), ["supervisor", "run", "--boot"]);
}
#[test]
fn service_args_record_invoking_user() {
let args = service_args(Some("alice"));
assert_eq!(
args,
["supervisor", "run", "--boot", "--invoking-user", "alice"]
);
let argv = std::iter::once("pitchfork".to_string())
.chain(args)
.map(std::ffi::OsString::from);
assert_eq!(
crate::env::invoking_user_arg(argv).as_deref(),
Some("alice")
);
}
}