use once_cell::sync::Lazy;
pub use std::env::*;
use std::path::PathBuf;
pub static PITCHFORK_BIN: Lazy<PathBuf> = Lazy::new(|| {
current_exe()
.and_then(|p| p.canonicalize())
.unwrap_or_else(|e| {
eprintln!("Warning: Could not determine pitchfork binary path: {e}");
args()
.next()
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("pitchfork"))
})
});
pub static CWD: Lazy<PathBuf> = Lazy::new(|| current_dir().unwrap_or_else(|_| PathBuf::from(".")));
pub static HOME_DIR: Lazy<PathBuf> = Lazy::new(|| {
#[cfg(unix)]
if let Some(home) = invoking_home_dir(
nix::unistd::Uid::effective().is_root(),
INVOKING_USER.as_ref().ok().and_then(Option::as_ref),
std::env::var("SUDO_USER").ok(),
) {
return home;
}
dirs::home_dir().unwrap_or_else(|| {
eprintln!("Warning: Could not determine home directory");
PathBuf::from("/tmp")
})
});
pub const INVOKING_USER_FLAG: &str = "--invoking-user";
#[cfg(unix)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InvokingUser {
pub name: String,
pub uid: u32,
pub gid: u32,
pub home: PathBuf,
}
#[cfg(unix)]
pub static INVOKING_USER: Lazy<std::result::Result<Option<InvokingUser>, String>> =
Lazy::new(|| {
resolve_invoking_user(
nix::unistd::Uid::effective().is_root(),
invoking_user_arg(args_os()).as_deref(),
lookup_user,
)
});
#[cfg(unix)]
pub fn invoking_user_arg(argv: impl IntoIterator<Item = std::ffi::OsString>) -> Option<String> {
let argv: Vec<String> = argv
.into_iter()
.skip(1)
.map(|arg| arg.to_string_lossy().into_owned())
.collect();
let [command, subcommand, rest @ ..] = argv.as_slice() else {
return None;
};
if !matches!(command.as_str(), "supervisor" | "sup") || subcommand != "run" {
return None;
}
let mut rest = rest.iter();
while let Some(arg) = rest.next() {
if arg == "--" {
break;
}
if arg == INVOKING_USER_FLAG {
return rest.next().cloned();
}
if let Some(value) = arg
.strip_prefix(INVOKING_USER_FLAG)
.and_then(|v| v.strip_prefix('='))
{
return Some(value.to_string());
}
}
None
}
#[cfg(unix)]
fn resolve_invoking_user(
is_root: bool,
recorded: Option<&str>,
lookup: impl Fn(&str) -> Option<InvokingUser>,
) -> std::result::Result<Option<InvokingUser>, String> {
let Some(recorded) = recorded else {
return Ok(None);
};
let recorded = recorded.trim();
if recorded.is_empty() {
return Err(format!("{INVOKING_USER_FLAG} requires a user name or UID"));
}
if !is_root {
return Err(format!(
"{INVOKING_USER_FLAG} {recorded} requires the supervisor to run as root"
));
}
lookup(recorded).map(Some).ok_or_else(|| {
format!(
"the account '{recorded}' recorded by {INVOKING_USER_FLAG} no longer exists; \
re-register boot start with `sudo pitchfork boot enable` from the account \
that should own this supervisor"
)
})
}
#[cfg(unix)]
fn lookup_user(spec: &str) -> Option<InvokingUser> {
let user = if spec.chars().all(|c| c.is_ascii_digit()) {
let uid = spec.parse::<u32>().ok()?;
nix::unistd::User::from_uid(nix::unistd::Uid::from_raw(uid))
} else {
nix::unistd::User::from_name(spec)
}
.ok()
.flatten()?;
Some(InvokingUser {
name: user.name,
uid: user.uid.as_raw(),
gid: user.gid.as_raw(),
home: user.dir,
})
}
#[cfg(any(target_os = "macos", target_os = "linux"))]
pub fn boot_service_invoking_user() -> crate::Result<Option<String>> {
if let Some(user) = INVOKING_USER.clone().map_err(|e| miette::miette!(e))? {
return Ok(Some(service_user_spec(&user)));
}
if !nix::unistd::Uid::effective().is_root() {
return Ok(None);
}
let Some(sudo_user) = std::env::var("SUDO_USER")
.ok()
.filter(|u| !u.is_empty() && u != "root")
else {
return Ok(None);
};
let user = lookup_user(&sudo_user)
.ok_or_else(|| miette::miette!("could not look up the sudo-calling user '{sudo_user}'"))?;
Ok(Some(service_user_spec(&user)))
}
#[cfg(any(target_os = "macos", target_os = "linux"))]
fn service_user_spec(user: &InvokingUser) -> String {
let safe_name = !user.name.is_empty()
&& !user.name.starts_with('-')
&& !user.name.chars().all(|c| c.is_ascii_digit())
&& user
.name
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-'));
if safe_name {
user.name.clone()
} else {
user.uid.to_string()
}
}
#[cfg(unix)]
pub fn invoking_user_ids() -> Option<(u32, u32)> {
resolve_invoking_ids(
nix::unistd::Uid::effective().is_root(),
INVOKING_USER.as_ref().ok().and_then(Option::as_ref),
std::env::var("SUDO_UID").ok(),
std::env::var("SUDO_GID").ok(),
)
}
#[cfg(unix)]
fn invoking_home_dir(
is_root: bool,
recorded: Option<&InvokingUser>,
sudo_user: Option<String>,
) -> Option<PathBuf> {
if !is_root {
return None;
}
if let Some(user) = recorded {
return Some(user.home.clone());
}
home_dir_for_user(&sudo_user?)
}
#[cfg(unix)]
fn resolve_invoking_ids(
is_root: bool,
recorded: Option<&InvokingUser>,
sudo_uid: Option<String>,
sudo_gid: Option<String>,
) -> Option<(u32, u32)> {
if !is_root {
return None;
}
if let Some(user) = recorded {
return Some((user.uid, user.gid));
}
let uid: u32 = sudo_uid?.parse().ok()?;
let gid: u32 = sudo_gid?.parse().ok()?;
Some((uid, gid))
}
pub static PITCHFORK_CONFIG_DIR: Lazy<PathBuf> = Lazy::new(|| {
var_path("PITCHFORK_CONFIG_DIR").unwrap_or(HOME_DIR.join(".config").join("pitchfork"))
});
pub static PITCHFORK_GLOBAL_CONFIG_USER: Lazy<PathBuf> =
Lazy::new(|| PITCHFORK_CONFIG_DIR.join("config.toml"));
pub static PITCHFORK_GLOBAL_CONFIG_SYSTEM: Lazy<PathBuf> =
Lazy::new(|| PathBuf::from("/etc/pitchfork/config.toml"));
pub static PITCHFORK_STATE_DIR: Lazy<PathBuf> = Lazy::new(|| {
if let Some(p) = var_path("PITCHFORK_STATE_DIR") {
return p;
}
#[cfg(unix)]
if nix::unistd::Uid::effective().is_root()
&& let Some(home) = configured_supervisor_user_home_dir()
{
return home.join(".local").join("state").join("pitchfork");
}
#[cfg(unix)]
if nix::unistd::Uid::effective().is_root() {
return HOME_DIR.join(".local").join("state").join("pitchfork");
}
dirs::state_dir()
.unwrap_or_else(|| HOME_DIR.join(".local").join("state"))
.join("pitchfork")
});
pub static PITCHFORK_STATE_FILE: Lazy<PathBuf> =
Lazy::new(|| PITCHFORK_STATE_DIR.join("state.toml"));
pub static PITCHFORK_HOSTS_FILE: Lazy<PathBuf> = Lazy::new(|| {
if let Some(p) = var_path("PITCHFORK_HOSTS_FILE") {
return p;
}
if cfg!(windows) {
let system_root = var("SystemRoot").unwrap_or_else(|_| r"C:\Windows".to_string());
PathBuf::from(system_root)
.join("System32")
.join("drivers")
.join("etc")
.join("hosts")
} else {
PathBuf::from("/etc/hosts")
}
});
pub static PITCHFORK_LOG: Lazy<log::LevelFilter> =
Lazy::new(|| var_log_level("PITCHFORK_LOG").unwrap_or(log::LevelFilter::Info));
pub static PITCHFORK_LOG_FILE_LEVEL: Lazy<log::LevelFilter> =
Lazy::new(|| var_log_level("PITCHFORK_LOG_FILE_LEVEL").unwrap_or(*PITCHFORK_LOG));
pub static PITCHFORK_LOGS_DIR: Lazy<PathBuf> =
Lazy::new(|| var_path("PITCHFORK_LOGS_DIR").unwrap_or(PITCHFORK_STATE_DIR.join("logs")));
pub static PITCHFORK_LOG_FILE: Lazy<PathBuf> =
Lazy::new(|| PITCHFORK_LOGS_DIR.join("pitchfork").join("pitchfork.log"));
#[cfg(unix)]
pub static IPC_SOCK_DIR: Lazy<PathBuf> = Lazy::new(|| PITCHFORK_STATE_DIR.join("sock"));
#[cfg(unix)]
pub static IPC_SOCK_MAIN: Lazy<PathBuf> = Lazy::new(|| IPC_SOCK_DIR.join("main.sock"));
pub static ORIGINAL_PATH: Lazy<Option<String>> = Lazy::new(|| var("PATH").ok());
pub fn expand_tilde(path: impl AsRef<std::path::Path>) -> PathBuf {
expand_tilde_for_user(path, None)
}
pub fn expand_tilde_for_user(path: impl AsRef<std::path::Path>, user: Option<&str>) -> PathBuf {
let path = path.as_ref();
match path.strip_prefix("~") {
Ok(rest) => home_dir_for_effective_user(user).join(rest),
Err(_) => path.to_path_buf(),
}
}
fn var_path(name: &str) -> Option<PathBuf> {
var(name).map(expand_tilde).ok()
}
fn var_log_level(name: &str) -> Option<log::LevelFilter> {
var(name).ok().and_then(|level| level.parse().ok())
}
#[cfg(unix)]
fn home_dir_for_user(username: &str) -> Option<PathBuf> {
nix::unistd::User::from_name(username)
.ok()
.flatten()
.map(|u| u.dir)
}
#[cfg(unix)]
fn home_dir_by_user_spec(user: &str) -> Option<PathBuf> {
if user.chars().all(|c| c.is_ascii_digit()) {
let uid = user.parse::<u32>().ok()?;
nix::unistd::User::from_uid(nix::unistd::Uid::from_raw(uid))
.ok()
.flatten()
.map(|u| u.dir)
} else {
home_dir_for_user(user)
}
}
#[cfg(unix)]
pub(crate) fn home_dir_for_effective_user(user: Option<&str>) -> PathBuf {
let user = user.map(str::trim).filter(|u| !u.is_empty());
match user {
Some(u) => home_dir_by_user_spec(u).unwrap_or_else(|| HOME_DIR.clone()),
None => HOME_DIR.clone(),
}
}
#[cfg(not(unix))]
pub(crate) fn home_dir_for_effective_user(_user: Option<&str>) -> PathBuf {
HOME_DIR.clone()
}
#[cfg(unix)]
fn configured_supervisor_user_home_dir() -> Option<PathBuf> {
let s = crate::settings::settings();
let user = s.supervisor.user.trim();
if user.is_empty() {
return None;
}
home_dir_by_user_spec(user)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
#[test]
fn expand_tilde_replaces_home_prefix() {
assert_eq!(
expand_tilde("~/projects/api"),
HOME_DIR.join("projects/api")
);
assert_eq!(expand_tilde("~"), *HOME_DIR);
}
#[test]
fn expand_tilde_leaves_other_paths_unchanged() {
assert_eq!(
expand_tilde("/srv/projects/api"),
Path::new("/srv/projects/api")
);
assert_eq!(expand_tilde("projects/api"), Path::new("projects/api"));
assert_eq!(expand_tilde("~other/api"), Path::new("~other/api"));
}
#[test]
fn expand_tilde_for_user_none_uses_supervisor_home() {
assert_eq!(expand_tilde_for_user("~/data", None), HOME_DIR.join("data"));
}
#[test]
fn expand_tilde_for_user_empty_uses_supervisor_home() {
assert_eq!(
expand_tilde_for_user("~/data", Some("")),
HOME_DIR.join("data")
);
}
#[test]
fn expand_tilde_for_user_nonexistent_falls_back_to_supervisor_home() {
assert_eq!(
expand_tilde_for_user("~/data", Some("nonexistent_user_xyz")),
HOME_DIR.join("data")
);
}
#[cfg(unix)]
fn argv(args: &[&str]) -> Vec<std::ffi::OsString> {
std::iter::once("pitchfork")
.chain(args.iter().copied())
.map(Into::into)
.collect()
}
#[cfg(unix)]
#[test]
fn invoking_user_arg_reads_supervisor_run_flag() {
for args in [
&["supervisor", "run", "--boot", "--invoking-user", "alice"][..],
&["supervisor", "run", "--invoking-user=alice", "--boot"],
&["sup", "run", "--invoking-user", "alice"],
] {
assert_eq!(invoking_user_arg(argv(args)).as_deref(), Some("alice"));
}
}
#[cfg(unix)]
#[test]
fn invoking_user_arg_ignores_other_commands() {
for args in [
&["supervisor", "run", "--boot"][..],
&["supervisor", "start", "--invoking-user", "alice"],
&["run", "--invoking-user", "alice"],
&["supervisor", "run", "--", "--invoking-user", "alice"],
&[],
] {
assert_eq!(invoking_user_arg(argv(args)), None);
}
}
#[cfg(unix)]
fn alice() -> InvokingUser {
InvokingUser {
name: "alice".into(),
uid: 501,
gid: 20,
home: PathBuf::from("/Users/alice"),
}
}
#[cfg(unix)]
fn lookup_alice(spec: &str) -> Option<InvokingUser> {
(spec == "alice" || spec == "501").then(alice)
}
#[cfg(unix)]
#[test]
fn recorded_user_replaces_missing_sudo_environment() {
let user = resolve_invoking_user(true, Some("alice"), lookup_alice)
.unwrap()
.unwrap();
assert_eq!(user, alice());
assert_eq!(
invoking_home_dir(true, Some(&user), None),
Some(PathBuf::from("/Users/alice"))
);
assert_eq!(
resolve_invoking_ids(true, Some(&user), None, None),
Some((501, 20))
);
assert_eq!(
resolve_invoking_user(true, Some("501"), lookup_alice).unwrap(),
Some(alice())
);
}
#[cfg(unix)]
#[test]
fn recorded_user_takes_precedence_over_sudo_environment() {
let user = alice();
assert_eq!(
resolve_invoking_ids(true, Some(&user), Some("502".into()), Some("30".into())),
Some((501, 20))
);
assert_eq!(
invoking_home_dir(true, Some(&user), Some("root".into())),
Some(PathBuf::from("/Users/alice"))
);
}
#[cfg(unix)]
#[test]
fn sudo_environment_still_applies_without_recorded_user() {
assert_eq!(
resolve_invoking_ids(true, None, Some("502".into()), Some("30".into())),
Some((502, 30))
);
}
#[cfg(unix)]
#[test]
fn root_shell_service_has_no_invoking_user() {
assert_eq!(resolve_invoking_user(true, None, lookup_alice), Ok(None));
assert_eq!(invoking_home_dir(true, None, None), None);
assert_eq!(resolve_invoking_ids(true, None, None, None), None);
}
#[cfg(unix)]
#[test]
fn missing_recorded_user_fails_instead_of_falling_back_to_root() {
let err = resolve_invoking_user(true, Some("bob"), lookup_alice).unwrap_err();
assert!(err.contains("'bob'"), "{err}");
assert!(err.contains("no longer exists"), "{err}");
}
#[cfg(unix)]
#[test]
fn recorded_user_requires_root() {
let err = resolve_invoking_user(false, Some("alice"), lookup_alice).unwrap_err();
assert!(
err.contains("requires the supervisor to run as root"),
"{err}"
);
let user = alice();
assert_eq!(invoking_home_dir(false, Some(&user), None), None);
assert_eq!(resolve_invoking_ids(false, Some(&user), None, None), None);
}
#[cfg(unix)]
#[test]
fn empty_recorded_user_is_rejected() {
assert!(resolve_invoking_user(true, Some(" "), lookup_alice).is_err());
}
#[cfg(any(target_os = "macos", target_os = "linux"))]
#[test]
fn service_user_spec_prefers_name_and_falls_back_to_uid() {
assert_eq!(service_user_spec(&alice()), "alice");
for name in ["alice smith", "-alice", "1234", "al$ice", ""] {
let user = InvokingUser {
name: name.into(),
..alice()
};
assert_eq!(service_user_spec(&user), "501", "{name:?}");
}
}
#[test]
fn expand_tilde_for_user_leaves_non_tilde_unchanged() {
assert_eq!(
expand_tilde_for_user("/srv/api", Some("postgres")),
Path::new("/srv/api")
);
}
}