1use once_cell::sync::Lazy;
2pub use std::env::*;
3use std::path::PathBuf;
4
5pub static PITCHFORK_BIN: Lazy<PathBuf> = Lazy::new(|| {
6 current_exe()
7 .and_then(|p| p.canonicalize())
8 .unwrap_or_else(|e| {
9 eprintln!("Warning: Could not determine pitchfork binary path: {e}");
10 args()
11 .next()
12 .map(PathBuf::from)
13 .unwrap_or_else(|| PathBuf::from("pitchfork"))
14 })
15});
16pub static CWD: Lazy<PathBuf> = Lazy::new(|| current_dir().unwrap_or_else(|_| PathBuf::from(".")));
17
18pub static HOME_DIR: Lazy<PathBuf> = Lazy::new(|| {
19 #[cfg(unix)]
33 if let Some(home) = invoking_home_dir(
34 nix::unistd::Uid::effective().is_root(),
35 INVOKING_USER.as_ref().ok().and_then(Option::as_ref),
36 std::env::var("SUDO_USER").ok(),
37 ) {
38 return home;
39 }
40 dirs::home_dir().unwrap_or_else(|| {
41 eprintln!("Warning: Could not determine home directory");
42 PathBuf::from("/tmp")
43 })
44});
45pub const INVOKING_USER_FLAG: &str = "--invoking-user";
48
49#[cfg(unix)]
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct InvokingUser {
61 pub name: String,
62 pub uid: u32,
63 pub gid: u32,
64 pub home: PathBuf,
65}
66
67#[cfg(unix)]
74pub static INVOKING_USER: Lazy<std::result::Result<Option<InvokingUser>, String>> =
75 Lazy::new(|| {
76 resolve_invoking_user(
77 nix::unistd::Uid::effective().is_root(),
78 invoking_user_arg(args_os()).as_deref(),
79 lookup_user,
80 )
81 });
82
83#[cfg(unix)]
89pub fn invoking_user_arg(argv: impl IntoIterator<Item = std::ffi::OsString>) -> Option<String> {
90 let argv: Vec<String> = argv
91 .into_iter()
92 .skip(1)
93 .map(|arg| arg.to_string_lossy().into_owned())
94 .collect();
95 let [command, subcommand, rest @ ..] = argv.as_slice() else {
96 return None;
97 };
98 if !matches!(command.as_str(), "supervisor" | "sup") || subcommand != "run" {
99 return None;
100 }
101 let mut rest = rest.iter();
102 while let Some(arg) = rest.next() {
103 if arg == "--" {
104 break;
105 }
106 if arg == INVOKING_USER_FLAG {
107 return rest.next().cloned();
108 }
109 if let Some(value) = arg
110 .strip_prefix(INVOKING_USER_FLAG)
111 .and_then(|v| v.strip_prefix('='))
112 {
113 return Some(value.to_string());
114 }
115 }
116 None
117}
118
119#[cfg(unix)]
120fn resolve_invoking_user(
121 is_root: bool,
122 recorded: Option<&str>,
123 lookup: impl Fn(&str) -> Option<InvokingUser>,
124) -> std::result::Result<Option<InvokingUser>, String> {
125 let Some(recorded) = recorded else {
126 return Ok(None);
127 };
128 let recorded = recorded.trim();
129 if recorded.is_empty() {
130 return Err(format!("{INVOKING_USER_FLAG} requires a user name or UID"));
131 }
132 if !is_root {
133 return Err(format!(
134 "{INVOKING_USER_FLAG} {recorded} requires the supervisor to run as root"
135 ));
136 }
137 lookup(recorded).map(Some).ok_or_else(|| {
138 format!(
139 "the account '{recorded}' recorded by {INVOKING_USER_FLAG} no longer exists; \
140 re-register boot start with `sudo pitchfork boot enable` from the account \
141 that should own this supervisor"
142 )
143 })
144}
145
146#[cfg(unix)]
147fn lookup_user(spec: &str) -> Option<InvokingUser> {
148 let user = if spec.chars().all(|c| c.is_ascii_digit()) {
149 let uid = spec.parse::<u32>().ok()?;
150 nix::unistd::User::from_uid(nix::unistd::Uid::from_raw(uid))
151 } else {
152 nix::unistd::User::from_name(spec)
153 }
154 .ok()
155 .flatten()?;
156 Some(InvokingUser {
157 name: user.name,
158 uid: user.uid.as_raw(),
159 gid: user.gid.as_raw(),
160 home: user.dir,
161 })
162}
163
164#[cfg(any(target_os = "macos", target_os = "linux"))]
171pub fn boot_service_invoking_user() -> crate::Result<Option<String>> {
172 if let Some(user) = INVOKING_USER.clone().map_err(|e| miette::miette!(e))? {
173 return Ok(Some(service_user_spec(&user)));
174 }
175 if !nix::unistd::Uid::effective().is_root() {
176 return Ok(None);
177 }
178 let Some(sudo_user) = std::env::var("SUDO_USER")
179 .ok()
180 .filter(|u| !u.is_empty() && u != "root")
181 else {
182 return Ok(None);
183 };
184 let user = lookup_user(&sudo_user)
185 .ok_or_else(|| miette::miette!("could not look up the sudo-calling user '{sudo_user}'"))?;
186 Ok(Some(service_user_spec(&user)))
187}
188
189#[cfg(any(target_os = "macos", target_os = "linux"))]
192fn service_user_spec(user: &InvokingUser) -> String {
193 let safe_name = !user.name.is_empty()
194 && !user.name.starts_with('-')
195 && !user.name.chars().all(|c| c.is_ascii_digit())
196 && user
197 .name
198 .chars()
199 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-'));
200 if safe_name {
201 user.name.clone()
202 } else {
203 user.uid.to_string()
204 }
205}
206
207#[cfg(unix)]
214pub fn invoking_user_ids() -> Option<(u32, u32)> {
215 resolve_invoking_ids(
216 nix::unistd::Uid::effective().is_root(),
217 INVOKING_USER.as_ref().ok().and_then(Option::as_ref),
218 std::env::var("SUDO_UID").ok(),
219 std::env::var("SUDO_GID").ok(),
220 )
221}
222
223#[cfg(unix)]
226fn invoking_home_dir(
227 is_root: bool,
228 recorded: Option<&InvokingUser>,
229 sudo_user: Option<String>,
230) -> Option<PathBuf> {
231 if !is_root {
232 return None;
233 }
234 if let Some(user) = recorded {
235 return Some(user.home.clone());
236 }
237 home_dir_for_user(&sudo_user?)
238}
239
240#[cfg(unix)]
241fn resolve_invoking_ids(
242 is_root: bool,
243 recorded: Option<&InvokingUser>,
244 sudo_uid: Option<String>,
245 sudo_gid: Option<String>,
246) -> Option<(u32, u32)> {
247 if !is_root {
248 return None;
249 }
250 if let Some(user) = recorded {
251 return Some((user.uid, user.gid));
252 }
253 let uid: u32 = sudo_uid?.parse().ok()?;
254 let gid: u32 = sudo_gid?.parse().ok()?;
255 Some((uid, gid))
256}
257
258pub static PITCHFORK_CONFIG_DIR: Lazy<PathBuf> = Lazy::new(|| {
259 var_path("PITCHFORK_CONFIG_DIR").unwrap_or(HOME_DIR.join(".config").join("pitchfork"))
260});
261pub static PITCHFORK_GLOBAL_CONFIG_USER: Lazy<PathBuf> =
262 Lazy::new(|| PITCHFORK_CONFIG_DIR.join("config.toml"));
263pub static PITCHFORK_GLOBAL_CONFIG_SYSTEM: Lazy<PathBuf> =
264 Lazy::new(|| PathBuf::from("/etc/pitchfork/config.toml"));
265pub static PITCHFORK_STATE_DIR: Lazy<PathBuf> = Lazy::new(|| {
266 if let Some(p) = var_path("PITCHFORK_STATE_DIR") {
267 return p;
268 }
269 #[cfg(unix)]
270 if nix::unistd::Uid::effective().is_root()
271 && let Some(home) = configured_supervisor_user_home_dir()
272 {
273 return home.join(".local").join("state").join("pitchfork");
274 }
275 #[cfg(unix)]
278 if nix::unistd::Uid::effective().is_root() {
279 return HOME_DIR.join(".local").join("state").join("pitchfork");
280 }
281 dirs::state_dir()
282 .unwrap_or_else(|| HOME_DIR.join(".local").join("state"))
283 .join("pitchfork")
284});
285pub static PITCHFORK_STATE_FILE: Lazy<PathBuf> =
286 Lazy::new(|| PITCHFORK_STATE_DIR.join("state.toml"));
287pub static PITCHFORK_HOSTS_FILE: Lazy<PathBuf> = Lazy::new(|| {
292 if let Some(p) = var_path("PITCHFORK_HOSTS_FILE") {
293 return p;
294 }
295 if cfg!(windows) {
296 let system_root = var("SystemRoot").unwrap_or_else(|_| r"C:\Windows".to_string());
297 PathBuf::from(system_root)
298 .join("System32")
299 .join("drivers")
300 .join("etc")
301 .join("hosts")
302 } else {
303 PathBuf::from("/etc/hosts")
304 }
305});
306pub static PITCHFORK_LOG: Lazy<log::LevelFilter> =
307 Lazy::new(|| var_log_level("PITCHFORK_LOG").unwrap_or(log::LevelFilter::Info));
308pub static PITCHFORK_LOG_FILE_LEVEL: Lazy<log::LevelFilter> =
309 Lazy::new(|| var_log_level("PITCHFORK_LOG_FILE_LEVEL").unwrap_or(*PITCHFORK_LOG));
310pub static PITCHFORK_LOGS_DIR: Lazy<PathBuf> =
311 Lazy::new(|| var_path("PITCHFORK_LOGS_DIR").unwrap_or(PITCHFORK_STATE_DIR.join("logs")));
312pub static PITCHFORK_LOG_FILE: Lazy<PathBuf> =
313 Lazy::new(|| PITCHFORK_LOGS_DIR.join("pitchfork").join("pitchfork.log"));
314#[cfg(unix)]
318pub static IPC_SOCK_DIR: Lazy<PathBuf> = Lazy::new(|| PITCHFORK_STATE_DIR.join("sock"));
319#[cfg(unix)]
320pub static IPC_SOCK_MAIN: Lazy<PathBuf> = Lazy::new(|| IPC_SOCK_DIR.join("main.sock"));
321
322pub static ORIGINAL_PATH: Lazy<Option<String>> = Lazy::new(|| var("PATH").ok());
324pub static IPC_JSON: Lazy<bool> = Lazy::new(|| !var_false("IPC_JSON"));
325
326pub fn expand_tilde(path: impl AsRef<std::path::Path>) -> PathBuf {
332 expand_tilde_for_user(path, None)
333}
334
335pub fn expand_tilde_for_user(path: impl AsRef<std::path::Path>, user: Option<&str>) -> PathBuf {
343 let path = path.as_ref();
344 match path.strip_prefix("~") {
345 Ok(rest) => home_dir_for_effective_user(user).join(rest),
346 Err(_) => path.to_path_buf(),
347 }
348}
349
350fn var_path(name: &str) -> Option<PathBuf> {
351 var(name).map(expand_tilde).ok()
352}
353
354fn var_log_level(name: &str) -> Option<log::LevelFilter> {
355 var(name).ok().and_then(|level| level.parse().ok())
356}
357
358fn var_false(name: &str) -> bool {
359 var(name)
360 .map(|val| val.to_lowercase())
361 .map(|val| val == "false" || val == "0")
362 .unwrap_or(false)
363}
364
365#[cfg(unix)]
375fn home_dir_for_user(username: &str) -> Option<PathBuf> {
376 nix::unistd::User::from_name(username)
377 .ok()
378 .flatten()
379 .map(|u| u.dir)
380}
381
382#[cfg(unix)]
384fn home_dir_by_user_spec(user: &str) -> Option<PathBuf> {
385 if user.chars().all(|c| c.is_ascii_digit()) {
386 let uid = user.parse::<u32>().ok()?;
387 nix::unistd::User::from_uid(nix::unistd::Uid::from_raw(uid))
388 .ok()
389 .flatten()
390 .map(|u| u.dir)
391 } else {
392 home_dir_for_user(user)
393 }
394}
395
396#[cfg(unix)]
400pub(crate) fn home_dir_for_effective_user(user: Option<&str>) -> PathBuf {
401 let user = user.map(str::trim).filter(|u| !u.is_empty());
402 match user {
403 Some(u) => home_dir_by_user_spec(u).unwrap_or_else(|| HOME_DIR.clone()),
404 None => HOME_DIR.clone(),
405 }
406}
407
408#[cfg(not(unix))]
409pub(crate) fn home_dir_for_effective_user(_user: Option<&str>) -> PathBuf {
410 HOME_DIR.clone()
411}
412
413#[cfg(unix)]
414fn configured_supervisor_user_home_dir() -> Option<PathBuf> {
415 let s = crate::settings::settings();
416 let user = s.supervisor.user.trim();
417 if user.is_empty() {
418 return None;
419 }
420 home_dir_by_user_spec(user)
421}
422
423#[cfg(test)]
424mod tests {
425 use super::*;
426 use std::path::Path;
427
428 #[test]
429 fn expand_tilde_replaces_home_prefix() {
430 assert_eq!(
431 expand_tilde("~/projects/api"),
432 HOME_DIR.join("projects/api")
433 );
434 assert_eq!(expand_tilde("~"), *HOME_DIR);
435 }
436
437 #[test]
438 fn expand_tilde_leaves_other_paths_unchanged() {
439 assert_eq!(
440 expand_tilde("/srv/projects/api"),
441 Path::new("/srv/projects/api")
442 );
443 assert_eq!(expand_tilde("projects/api"), Path::new("projects/api"));
444 assert_eq!(expand_tilde("~other/api"), Path::new("~other/api"));
445 }
446
447 #[test]
448 fn expand_tilde_for_user_none_uses_supervisor_home() {
449 assert_eq!(expand_tilde_for_user("~/data", None), HOME_DIR.join("data"));
450 }
451
452 #[test]
453 fn expand_tilde_for_user_empty_uses_supervisor_home() {
454 assert_eq!(
455 expand_tilde_for_user("~/data", Some("")),
456 HOME_DIR.join("data")
457 );
458 }
459
460 #[test]
461 fn expand_tilde_for_user_nonexistent_falls_back_to_supervisor_home() {
462 assert_eq!(
463 expand_tilde_for_user("~/data", Some("nonexistent_user_xyz")),
464 HOME_DIR.join("data")
465 );
466 }
467
468 #[cfg(unix)]
469 fn argv(args: &[&str]) -> Vec<std::ffi::OsString> {
470 std::iter::once("pitchfork")
471 .chain(args.iter().copied())
472 .map(Into::into)
473 .collect()
474 }
475
476 #[cfg(unix)]
477 #[test]
478 fn invoking_user_arg_reads_supervisor_run_flag() {
479 for args in [
480 &["supervisor", "run", "--boot", "--invoking-user", "alice"][..],
481 &["supervisor", "run", "--invoking-user=alice", "--boot"],
482 &["sup", "run", "--invoking-user", "alice"],
483 ] {
484 assert_eq!(invoking_user_arg(argv(args)).as_deref(), Some("alice"));
485 }
486 }
487
488 #[cfg(unix)]
489 #[test]
490 fn invoking_user_arg_ignores_other_commands() {
491 for args in [
492 &["supervisor", "run", "--boot"][..],
493 &["supervisor", "start", "--invoking-user", "alice"],
494 &["run", "--invoking-user", "alice"],
495 &["supervisor", "run", "--", "--invoking-user", "alice"],
496 &[],
497 ] {
498 assert_eq!(invoking_user_arg(argv(args)), None);
499 }
500 }
501
502 #[cfg(unix)]
503 fn alice() -> InvokingUser {
504 InvokingUser {
505 name: "alice".into(),
506 uid: 501,
507 gid: 20,
508 home: PathBuf::from("/Users/alice"),
509 }
510 }
511
512 #[cfg(unix)]
513 fn lookup_alice(spec: &str) -> Option<InvokingUser> {
514 (spec == "alice" || spec == "501").then(alice)
515 }
516
517 #[cfg(unix)]
521 #[test]
522 fn recorded_user_replaces_missing_sudo_environment() {
523 let user = resolve_invoking_user(true, Some("alice"), lookup_alice)
524 .unwrap()
525 .unwrap();
526 assert_eq!(user, alice());
527 assert_eq!(
528 invoking_home_dir(true, Some(&user), None),
529 Some(PathBuf::from("/Users/alice"))
530 );
531 assert_eq!(
532 resolve_invoking_ids(true, Some(&user), None, None),
533 Some((501, 20))
534 );
535 assert_eq!(
536 resolve_invoking_user(true, Some("501"), lookup_alice).unwrap(),
537 Some(alice())
538 );
539 }
540
541 #[cfg(unix)]
542 #[test]
543 fn recorded_user_takes_precedence_over_sudo_environment() {
544 let user = alice();
545 assert_eq!(
546 resolve_invoking_ids(true, Some(&user), Some("502".into()), Some("30".into())),
547 Some((501, 20))
548 );
549 assert_eq!(
550 invoking_home_dir(true, Some(&user), Some("root".into())),
551 Some(PathBuf::from("/Users/alice"))
552 );
553 }
554
555 #[cfg(unix)]
556 #[test]
557 fn sudo_environment_still_applies_without_recorded_user() {
558 assert_eq!(
559 resolve_invoking_ids(true, None, Some("502".into()), Some("30".into())),
560 Some((502, 30))
561 );
562 }
563
564 #[cfg(unix)]
567 #[test]
568 fn root_shell_service_has_no_invoking_user() {
569 assert_eq!(resolve_invoking_user(true, None, lookup_alice), Ok(None));
570 assert_eq!(invoking_home_dir(true, None, None), None);
571 assert_eq!(resolve_invoking_ids(true, None, None, None), None);
572 }
573
574 #[cfg(unix)]
575 #[test]
576 fn missing_recorded_user_fails_instead_of_falling_back_to_root() {
577 let err = resolve_invoking_user(true, Some("bob"), lookup_alice).unwrap_err();
578 assert!(err.contains("'bob'"), "{err}");
579 assert!(err.contains("no longer exists"), "{err}");
580 }
581
582 #[cfg(unix)]
583 #[test]
584 fn recorded_user_requires_root() {
585 let err = resolve_invoking_user(false, Some("alice"), lookup_alice).unwrap_err();
586 assert!(
587 err.contains("requires the supervisor to run as root"),
588 "{err}"
589 );
590 let user = alice();
591 assert_eq!(invoking_home_dir(false, Some(&user), None), None);
592 assert_eq!(resolve_invoking_ids(false, Some(&user), None, None), None);
593 }
594
595 #[cfg(unix)]
596 #[test]
597 fn empty_recorded_user_is_rejected() {
598 assert!(resolve_invoking_user(true, Some(" "), lookup_alice).is_err());
599 }
600
601 #[cfg(any(target_os = "macos", target_os = "linux"))]
602 #[test]
603 fn service_user_spec_prefers_name_and_falls_back_to_uid() {
604 assert_eq!(service_user_spec(&alice()), "alice");
605 for name in ["alice smith", "-alice", "1234", "al$ice", ""] {
606 let user = InvokingUser {
607 name: name.into(),
608 ..alice()
609 };
610 assert_eq!(service_user_spec(&user), "501", "{name:?}");
611 }
612 }
613
614 #[test]
615 fn expand_tilde_for_user_leaves_non_tilde_unchanged() {
616 assert_eq!(
617 expand_tilde_for_user("/srv/api", Some("postgres")),
618 Path::new("/srv/api")
619 );
620 }
621}