Skip to main content

pitchfork_cli/
env.rs

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    // When running under `sudo`, HOME points to /var/root (macOS) or /root (Linux).
20    // Resolve the *original* user's home via SUDO_USER so all derived paths
21    // (state file, IPC socket, config, logs) remain consistent with the
22    // non-sudo invocation. This prevents a second supervisor instance from
23    // being spawned in a separate directory tree.
24    //
25    // Guard: only honour SUDO_USER when the effective UID is 0 (i.e. we are
26    // actually running as root). SUDO_USER can leak into non-sudo environments
27    // (e.g. inherited env, containers) and would misdirect all state paths.
28    //
29    // A system boot service has no sudo environment, so `sudo pitchfork boot
30    // enable` records the invoking user as `supervisor run --invoking-user`
31    // instead. That explicit record takes precedence over SUDO_USER.
32    #[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});
45/// Flag that records, in a system boot registration, the user who installed it
46/// through sudo. See [`InvokingUser`].
47pub const INVOKING_USER_FLAG: &str = "--invoking-user";
48
49/// The account a root supervisor acts on behalf of, recorded explicitly with
50/// `supervisor run --invoking-user <user>`.
51///
52/// `sudo pitchfork boot enable` writes this flag into the system service
53/// because launchd and systemd start the service without `SUDO_USER`,
54/// `SUDO_UID` and `SUDO_GID`. The recorded account then stands in for those
55/// variables: it supplies the home directory used for configuration and state
56/// lookup, the owner of state files and IPC sockets, and the default daemon
57/// identity, exactly as an interactive `sudo` invocation would.
58#[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/// The recorded invoking user of this process, when it is a root
68/// `supervisor run --invoking-user <user>`.
69///
70/// `Err` holds a message explaining why the recorded account could not be used;
71/// `supervisor run` refuses to start in that case rather than falling back to
72/// root's configuration and identity.
73#[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/// Extract the value of `--invoking-user` from a `supervisor run` command line.
84///
85/// Configuration and state paths are resolved before the CLI is parsed, so the
86/// flag is read directly from argv. Only `supervisor run` (or its `sup` alias)
87/// accepts it; any other command line yields `None`.
88#[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/// The user a system boot registration should record, if any.
165///
166/// Inside a supervisor started from such a registration, this is the recorded
167/// user, so re-registering (for example after a binary upgrade) keeps it.
168/// Otherwise it is the non-root user who ran this process through sudo. A
169/// root login shell records nothing and keeps a plain root service.
170#[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/// The form of `user` written into a service definition: the user name when
190/// it is safe to embed unquoted in a systemd `ExecStart=` line, else the UID.
191#[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/// UID and GID of the user a root supervisor acts on behalf of: the recorded
208/// invoking user, else `SUDO_UID`/`SUDO_GID`.
209///
210/// Returns `None` unless the effective UID is 0 (root). This prevents stale
211/// `SUDO_UID`/`SUDO_GID` values inherited into non-sudo environments from
212/// being used.
213#[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/// Home directory of the user a root process acts on behalf of: the recorded
224/// invoking user, else `SUDO_USER`. `None` means the process's own home.
225#[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    // Under sudo, dirs::state_dir() would resolve against root's HOME,
276    // bypassing our SUDO_USER correction. Use HOME_DIR directly instead.
277    #[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"));
287/// Path to the hosts file managed by the proxy's hosts sync.
288///
289/// `PITCHFORK_HOSTS_FILE` overrides the platform default; tests use it to
290/// keep the sync away from the real system hosts file.
291pub 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// pub static PITCHFORK_EXEC: Lazy<bool> = Lazy::new(|| var_true("PITCHFORK_EXEC"));
315
316// Unix domain sockets only; Windows IPC uses named pipes, see `ipc::fs_name`.
317#[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
322// Capture the PATH at startup so daemons can find user tools
323pub static ORIGINAL_PATH: Lazy<Option<String>> = Lazy::new(|| var("PATH").ok());
324
325/// Expand a leading `~` path component to the current Pitchfork user's home.
326///
327/// This intentionally supports only `~` and `~/...`, not `~user` or shell
328/// expansions such as `$HOME`. Pitchfork's home resolution accounts for the
329/// original user when running under `sudo`.
330pub fn expand_tilde(path: impl AsRef<std::path::Path>) -> PathBuf {
331    expand_tilde_for_user(path, None)
332}
333
334/// Expand a leading `~` to the home directory of `user`.
335///
336/// When `user` is `None`, empty, or the system lookup fails, falls back to
337/// `HOME_DIR` (the supervisor's home). This matches Unix semantics where `~`
338/// in a process's working directory refers to that process's effective user.
339///
340/// Only `~` and `~/...` are supported — not `~user` or shell expansions.
341pub fn expand_tilde_for_user(path: impl AsRef<std::path::Path>, user: Option<&str>) -> PathBuf {
342    let path = path.as_ref();
343    match path.strip_prefix("~") {
344        Ok(rest) => home_dir_for_effective_user(user).join(rest),
345        Err(_) => path.to_path_buf(),
346    }
347}
348
349fn var_path(name: &str) -> Option<PathBuf> {
350    var(name).map(expand_tilde).ok()
351}
352
353fn var_log_level(name: &str) -> Option<log::LevelFilter> {
354    var(name).ok().and_then(|level| level.parse().ok())
355}
356
357// fn var_true(name: &str) -> bool {
358//     var(name)
359//         .map(|val| val.to_lowercase())
360//         .map(|val| val == "true" || val == "1")
361//         .unwrap_or(false)
362// }
363
364/// Look up a user's home directory via the system password database.
365/// Returns `None` if the user does not exist or the lookup fails.
366#[cfg(unix)]
367fn home_dir_for_user(username: &str) -> Option<PathBuf> {
368    nix::unistd::User::from_name(username)
369        .ok()
370        .flatten()
371        .map(|u| u.dir)
372}
373
374/// Look up a home directory by username or numeric UID string.
375#[cfg(unix)]
376fn home_dir_by_user_spec(user: &str) -> Option<PathBuf> {
377    if user.chars().all(|c| c.is_ascii_digit()) {
378        let uid = user.parse::<u32>().ok()?;
379        nix::unistd::User::from_uid(nix::unistd::Uid::from_raw(uid))
380            .ok()
381            .flatten()
382            .map(|u| u.dir)
383    } else {
384        home_dir_for_user(user)
385    }
386}
387
388/// Resolve the home directory for an effective daemon user.
389///
390/// Returns `HOME_DIR` when `user` is `None`, empty, or the lookup fails.
391#[cfg(unix)]
392pub(crate) fn home_dir_for_effective_user(user: Option<&str>) -> PathBuf {
393    let user = user.map(str::trim).filter(|u| !u.is_empty());
394    match user {
395        Some(u) => home_dir_by_user_spec(u).unwrap_or_else(|| HOME_DIR.clone()),
396        None => HOME_DIR.clone(),
397    }
398}
399
400#[cfg(not(unix))]
401pub(crate) fn home_dir_for_effective_user(_user: Option<&str>) -> PathBuf {
402    HOME_DIR.clone()
403}
404
405#[cfg(unix)]
406fn configured_supervisor_user_home_dir() -> Option<PathBuf> {
407    let s = crate::settings::settings();
408    let user = s.supervisor.user.trim();
409    if user.is_empty() {
410        return None;
411    }
412    home_dir_by_user_spec(user)
413}
414
415#[cfg(test)]
416mod tests {
417    use super::*;
418    use std::path::Path;
419
420    #[test]
421    fn expand_tilde_replaces_home_prefix() {
422        assert_eq!(
423            expand_tilde("~/projects/api"),
424            HOME_DIR.join("projects/api")
425        );
426        assert_eq!(expand_tilde("~"), *HOME_DIR);
427    }
428
429    #[test]
430    fn expand_tilde_leaves_other_paths_unchanged() {
431        assert_eq!(
432            expand_tilde("/srv/projects/api"),
433            Path::new("/srv/projects/api")
434        );
435        assert_eq!(expand_tilde("projects/api"), Path::new("projects/api"));
436        assert_eq!(expand_tilde("~other/api"), Path::new("~other/api"));
437    }
438
439    #[test]
440    fn expand_tilde_for_user_none_uses_supervisor_home() {
441        assert_eq!(expand_tilde_for_user("~/data", None), HOME_DIR.join("data"));
442    }
443
444    #[test]
445    fn expand_tilde_for_user_empty_uses_supervisor_home() {
446        assert_eq!(
447            expand_tilde_for_user("~/data", Some("")),
448            HOME_DIR.join("data")
449        );
450    }
451
452    #[test]
453    fn expand_tilde_for_user_nonexistent_falls_back_to_supervisor_home() {
454        assert_eq!(
455            expand_tilde_for_user("~/data", Some("nonexistent_user_xyz")),
456            HOME_DIR.join("data")
457        );
458    }
459
460    #[cfg(unix)]
461    fn argv(args: &[&str]) -> Vec<std::ffi::OsString> {
462        std::iter::once("pitchfork")
463            .chain(args.iter().copied())
464            .map(Into::into)
465            .collect()
466    }
467
468    #[cfg(unix)]
469    #[test]
470    fn invoking_user_arg_reads_supervisor_run_flag() {
471        for args in [
472            &["supervisor", "run", "--boot", "--invoking-user", "alice"][..],
473            &["supervisor", "run", "--invoking-user=alice", "--boot"],
474            &["sup", "run", "--invoking-user", "alice"],
475        ] {
476            assert_eq!(invoking_user_arg(argv(args)).as_deref(), Some("alice"));
477        }
478    }
479
480    #[cfg(unix)]
481    #[test]
482    fn invoking_user_arg_ignores_other_commands() {
483        for args in [
484            &["supervisor", "run", "--boot"][..],
485            &["supervisor", "start", "--invoking-user", "alice"],
486            &["run", "--invoking-user", "alice"],
487            &["supervisor", "run", "--", "--invoking-user", "alice"],
488            &[],
489        ] {
490            assert_eq!(invoking_user_arg(argv(args)), None);
491        }
492    }
493
494    #[cfg(unix)]
495    fn alice() -> InvokingUser {
496        InvokingUser {
497            name: "alice".into(),
498            uid: 501,
499            gid: 20,
500            home: PathBuf::from("/Users/alice"),
501        }
502    }
503
504    #[cfg(unix)]
505    fn lookup_alice(spec: &str) -> Option<InvokingUser> {
506        (spec == "alice" || spec == "501").then(alice)
507    }
508
509    /// A boot service started by launchd or systemd has no SUDO_* variables;
510    /// the recorded user alone must supply the home, ownership, and identity
511    /// that an interactive sudo invocation would.
512    #[cfg(unix)]
513    #[test]
514    fn recorded_user_replaces_missing_sudo_environment() {
515        let user = resolve_invoking_user(true, Some("alice"), lookup_alice)
516            .unwrap()
517            .unwrap();
518        assert_eq!(user, alice());
519        assert_eq!(
520            invoking_home_dir(true, Some(&user), None),
521            Some(PathBuf::from("/Users/alice"))
522        );
523        assert_eq!(
524            resolve_invoking_ids(true, Some(&user), None, None),
525            Some((501, 20))
526        );
527        assert_eq!(
528            resolve_invoking_user(true, Some("501"), lookup_alice).unwrap(),
529            Some(alice())
530        );
531    }
532
533    #[cfg(unix)]
534    #[test]
535    fn recorded_user_takes_precedence_over_sudo_environment() {
536        let user = alice();
537        assert_eq!(
538            resolve_invoking_ids(true, Some(&user), Some("502".into()), Some("30".into())),
539            Some((501, 20))
540        );
541        assert_eq!(
542            invoking_home_dir(true, Some(&user), Some("root".into())),
543            Some(PathBuf::from("/Users/alice"))
544        );
545    }
546
547    #[cfg(unix)]
548    #[test]
549    fn sudo_environment_still_applies_without_recorded_user() {
550        assert_eq!(
551            resolve_invoking_ids(true, None, Some("502".into()), Some("30".into())),
552            Some((502, 30))
553        );
554    }
555
556    /// A service registered from a root login shell records no user and has no
557    /// sudo environment: it keeps running entirely as root.
558    #[cfg(unix)]
559    #[test]
560    fn root_shell_service_has_no_invoking_user() {
561        assert_eq!(resolve_invoking_user(true, None, lookup_alice), Ok(None));
562        assert_eq!(invoking_home_dir(true, None, None), None);
563        assert_eq!(resolve_invoking_ids(true, None, None, None), None);
564    }
565
566    #[cfg(unix)]
567    #[test]
568    fn missing_recorded_user_fails_instead_of_falling_back_to_root() {
569        let err = resolve_invoking_user(true, Some("bob"), lookup_alice).unwrap_err();
570        assert!(err.contains("'bob'"), "{err}");
571        assert!(err.contains("no longer exists"), "{err}");
572    }
573
574    #[cfg(unix)]
575    #[test]
576    fn recorded_user_requires_root() {
577        let err = resolve_invoking_user(false, Some("alice"), lookup_alice).unwrap_err();
578        assert!(
579            err.contains("requires the supervisor to run as root"),
580            "{err}"
581        );
582        let user = alice();
583        assert_eq!(invoking_home_dir(false, Some(&user), None), None);
584        assert_eq!(resolve_invoking_ids(false, Some(&user), None, None), None);
585    }
586
587    #[cfg(unix)]
588    #[test]
589    fn empty_recorded_user_is_rejected() {
590        assert!(resolve_invoking_user(true, Some(" "), lookup_alice).is_err());
591    }
592
593    #[cfg(any(target_os = "macos", target_os = "linux"))]
594    #[test]
595    fn service_user_spec_prefers_name_and_falls_back_to_uid() {
596        assert_eq!(service_user_spec(&alice()), "alice");
597        for name in ["alice smith", "-alice", "1234", "al$ice", ""] {
598            let user = InvokingUser {
599                name: name.into(),
600                ..alice()
601            };
602            assert_eq!(service_user_spec(&user), "501", "{name:?}");
603        }
604    }
605
606    #[test]
607    fn expand_tilde_for_user_leaves_non_tilde_unchanged() {
608        assert_eq!(
609            expand_tilde_for_user("/srv/api", Some("postgres")),
610            Path::new("/srv/api")
611        );
612    }
613}