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    #[cfg(unix)]
29    if nix::unistd::Uid::effective().is_root()
30        && let Ok(sudo_user) = std::env::var("SUDO_USER")
31        && let Some(home) = home_dir_for_user(&sudo_user)
32    {
33        return home;
34    }
35    dirs::home_dir().unwrap_or_else(|| {
36        eprintln!("Warning: Could not determine home directory");
37        PathBuf::from("/tmp")
38    })
39});
40pub static PITCHFORK_CONFIG_DIR: Lazy<PathBuf> = Lazy::new(|| {
41    var_path("PITCHFORK_CONFIG_DIR").unwrap_or(HOME_DIR.join(".config").join("pitchfork"))
42});
43pub static PITCHFORK_GLOBAL_CONFIG_USER: Lazy<PathBuf> =
44    Lazy::new(|| PITCHFORK_CONFIG_DIR.join("config.toml"));
45pub static PITCHFORK_GLOBAL_CONFIG_SYSTEM: Lazy<PathBuf> =
46    Lazy::new(|| PathBuf::from("/etc/pitchfork/config.toml"));
47pub static PITCHFORK_STATE_DIR: Lazy<PathBuf> = Lazy::new(|| {
48    if let Some(p) = var_path("PITCHFORK_STATE_DIR") {
49        return p;
50    }
51    #[cfg(unix)]
52    if nix::unistd::Uid::effective().is_root()
53        && let Some(home) = configured_supervisor_user_home_dir()
54    {
55        return home.join(".local").join("state").join("pitchfork");
56    }
57    // Under sudo, dirs::state_dir() would resolve against root's HOME,
58    // bypassing our SUDO_USER correction. Use HOME_DIR directly instead.
59    #[cfg(unix)]
60    if nix::unistd::Uid::effective().is_root() {
61        return HOME_DIR.join(".local").join("state").join("pitchfork");
62    }
63    dirs::state_dir()
64        .unwrap_or_else(|| HOME_DIR.join(".local").join("state"))
65        .join("pitchfork")
66});
67pub static PITCHFORK_STATE_FILE: Lazy<PathBuf> =
68    Lazy::new(|| PITCHFORK_STATE_DIR.join("state.toml"));
69/// Path to the hosts file managed by the proxy's hosts sync.
70///
71/// `PITCHFORK_HOSTS_FILE` overrides the platform default; tests use it to
72/// keep the sync away from the real system hosts file.
73pub static PITCHFORK_HOSTS_FILE: Lazy<PathBuf> = Lazy::new(|| {
74    if let Some(p) = var_path("PITCHFORK_HOSTS_FILE") {
75        return p;
76    }
77    if cfg!(windows) {
78        let system_root = var("SystemRoot").unwrap_or_else(|_| r"C:\Windows".to_string());
79        PathBuf::from(system_root)
80            .join("System32")
81            .join("drivers")
82            .join("etc")
83            .join("hosts")
84    } else {
85        PathBuf::from("/etc/hosts")
86    }
87});
88pub static PITCHFORK_LOG: Lazy<log::LevelFilter> =
89    Lazy::new(|| var_log_level("PITCHFORK_LOG").unwrap_or(log::LevelFilter::Info));
90pub static PITCHFORK_LOG_FILE_LEVEL: Lazy<log::LevelFilter> =
91    Lazy::new(|| var_log_level("PITCHFORK_LOG_FILE_LEVEL").unwrap_or(*PITCHFORK_LOG));
92pub static PITCHFORK_LOGS_DIR: Lazy<PathBuf> =
93    Lazy::new(|| var_path("PITCHFORK_LOGS_DIR").unwrap_or(PITCHFORK_STATE_DIR.join("logs")));
94pub static PITCHFORK_LOG_FILE: Lazy<PathBuf> =
95    Lazy::new(|| PITCHFORK_LOGS_DIR.join("pitchfork").join("pitchfork.log"));
96// pub static PITCHFORK_EXEC: Lazy<bool> = Lazy::new(|| var_true("PITCHFORK_EXEC"));
97
98// Unix domain sockets only; Windows IPC uses named pipes, see `ipc::fs_name`.
99#[cfg(unix)]
100pub static IPC_SOCK_DIR: Lazy<PathBuf> = Lazy::new(|| PITCHFORK_STATE_DIR.join("sock"));
101#[cfg(unix)]
102pub static IPC_SOCK_MAIN: Lazy<PathBuf> = Lazy::new(|| IPC_SOCK_DIR.join("main.sock"));
103
104// Capture the PATH at startup so daemons can find user tools
105pub static ORIGINAL_PATH: Lazy<Option<String>> = Lazy::new(|| var("PATH").ok());
106pub static IPC_JSON: Lazy<bool> = Lazy::new(|| !var_false("IPC_JSON"));
107
108/// Expand a leading `~` path component to the current Pitchfork user's home.
109///
110/// This intentionally supports only `~` and `~/...`, not `~user` or shell
111/// expansions such as `$HOME`. Pitchfork's home resolution accounts for the
112/// original user when running under `sudo`.
113pub fn expand_tilde(path: impl AsRef<std::path::Path>) -> PathBuf {
114    expand_tilde_for_user(path, None)
115}
116
117/// Expand a leading `~` to the home directory of `user`.
118///
119/// When `user` is `None`, empty, or the system lookup fails, falls back to
120/// `HOME_DIR` (the supervisor's home). This matches Unix semantics where `~`
121/// in a process's working directory refers to that process's effective user.
122///
123/// Only `~` and `~/...` are supported — not `~user` or shell expansions.
124pub fn expand_tilde_for_user(path: impl AsRef<std::path::Path>, user: Option<&str>) -> PathBuf {
125    let path = path.as_ref();
126    match path.strip_prefix("~") {
127        Ok(rest) => home_dir_for_effective_user(user).join(rest),
128        Err(_) => path.to_path_buf(),
129    }
130}
131
132fn var_path(name: &str) -> Option<PathBuf> {
133    var(name).map(expand_tilde).ok()
134}
135
136fn var_log_level(name: &str) -> Option<log::LevelFilter> {
137    var(name).ok().and_then(|level| level.parse().ok())
138}
139
140fn var_false(name: &str) -> bool {
141    var(name)
142        .map(|val| val.to_lowercase())
143        .map(|val| val == "false" || val == "0")
144        .unwrap_or(false)
145}
146
147// fn var_true(name: &str) -> bool {
148//     var(name)
149//         .map(|val| val.to_lowercase())
150//         .map(|val| val == "true" || val == "1")
151//         .unwrap_or(false)
152// }
153
154/// Look up a user's home directory via the system password database.
155/// Returns `None` if the user does not exist or the lookup fails.
156#[cfg(unix)]
157fn home_dir_for_user(username: &str) -> Option<PathBuf> {
158    nix::unistd::User::from_name(username)
159        .ok()
160        .flatten()
161        .map(|u| u.dir)
162}
163
164/// Look up a home directory by username or numeric UID string.
165#[cfg(unix)]
166fn home_dir_by_user_spec(user: &str) -> Option<PathBuf> {
167    if user.chars().all(|c| c.is_ascii_digit()) {
168        let uid = user.parse::<u32>().ok()?;
169        nix::unistd::User::from_uid(nix::unistd::Uid::from_raw(uid))
170            .ok()
171            .flatten()
172            .map(|u| u.dir)
173    } else {
174        home_dir_for_user(user)
175    }
176}
177
178/// Resolve the home directory for an effective daemon user.
179///
180/// Returns `HOME_DIR` when `user` is `None`, empty, or the lookup fails.
181#[cfg(unix)]
182pub(crate) fn home_dir_for_effective_user(user: Option<&str>) -> PathBuf {
183    let user = user.map(str::trim).filter(|u| !u.is_empty());
184    match user {
185        Some(u) => home_dir_by_user_spec(u).unwrap_or_else(|| HOME_DIR.clone()),
186        None => HOME_DIR.clone(),
187    }
188}
189
190#[cfg(not(unix))]
191pub(crate) fn home_dir_for_effective_user(_user: Option<&str>) -> PathBuf {
192    HOME_DIR.clone()
193}
194
195#[cfg(unix)]
196fn configured_supervisor_user_home_dir() -> Option<PathBuf> {
197    let s = crate::settings::settings();
198    let user = s.supervisor.user.trim();
199    if user.is_empty() {
200        return None;
201    }
202    home_dir_by_user_spec(user)
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208    use std::path::Path;
209
210    #[test]
211    fn expand_tilde_replaces_home_prefix() {
212        assert_eq!(
213            expand_tilde("~/projects/api"),
214            HOME_DIR.join("projects/api")
215        );
216        assert_eq!(expand_tilde("~"), *HOME_DIR);
217    }
218
219    #[test]
220    fn expand_tilde_leaves_other_paths_unchanged() {
221        assert_eq!(
222            expand_tilde("/srv/projects/api"),
223            Path::new("/srv/projects/api")
224        );
225        assert_eq!(expand_tilde("projects/api"), Path::new("projects/api"));
226        assert_eq!(expand_tilde("~other/api"), Path::new("~other/api"));
227    }
228
229    #[test]
230    fn expand_tilde_for_user_none_uses_supervisor_home() {
231        assert_eq!(expand_tilde_for_user("~/data", None), HOME_DIR.join("data"));
232    }
233
234    #[test]
235    fn expand_tilde_for_user_empty_uses_supervisor_home() {
236        assert_eq!(
237            expand_tilde_for_user("~/data", Some("")),
238            HOME_DIR.join("data")
239        );
240    }
241
242    #[test]
243    fn expand_tilde_for_user_nonexistent_falls_back_to_supervisor_home() {
244        assert_eq!(
245            expand_tilde_for_user("~/data", Some("nonexistent_user_xyz")),
246            HOME_DIR.join("data")
247        );
248    }
249
250    #[test]
251    fn expand_tilde_for_user_leaves_non_tilde_unchanged() {
252        assert_eq!(
253            expand_tilde_for_user("/srv/api", Some("postgres")),
254            Path::new("/srv/api")
255        );
256    }
257}