Skip to main content

aft/
effective_path.rs

1//! Process-wide PATH enrichment for children spawned by AFT.
2//!
3//! Daemon launches can inherit a system-only PATH that misses the user's package
4//! managers and version-manager shims. AFT initializes this module before any
5//! helper threads start so later subprocesses inherit the same PATH a login
6//! terminal would provide.
7
8use std::ffi::{OsStr, OsString};
9use std::sync::OnceLock;
10
11#[cfg(unix)]
12use std::collections::HashSet;
13#[cfg(unix)]
14use std::path::{Path, PathBuf};
15#[cfg(unix)]
16use std::process::{Command, Stdio};
17#[cfg(unix)]
18use std::time::{Duration, Instant};
19
20#[cfg(unix)]
21use std::os::unix::ffi::{OsStrExt, OsStringExt};
22#[cfg(unix)]
23use std::os::unix::process::CommandExt;
24
25static EFFECTIVE_PATH: OnceLock<OsString> = OnceLock::new();
26
27#[cfg(unix)]
28const LOGIN_SHELL_PATH_PROBE_TIMEOUT: Duration = Duration::from_secs(3);
29
30/// Compute and export AFT's process PATH.
31///
32/// Call this during process startup, before AFT starts worker threads or async
33/// executors. Mutating process environment variables while other threads may be
34/// reading them is not safe on Unix, so later code should read the cached value
35/// with [`effective_path`] instead of calling this initializer again.
36pub fn initialize_process_path() -> &'static OsStr {
37    let path = effective_path();
38
39    #[cfg(unix)]
40    {
41        if path != OsStr::new("") && std::env::var_os("PATH").as_deref() != Some(path) {
42            std::env::set_var("PATH", path);
43        }
44    }
45
46    path
47}
48
49/// Return the cached PATH that subprocesses should inherit.
50///
51/// On Windows this is the process PATH unchanged: Windows daemon environments
52/// already receive PATH from the registry-backed environment block.
53pub fn effective_path() -> &'static OsStr {
54    EFFECTIVE_PATH
55        .get_or_init(compute_effective_path)
56        .as_os_str()
57}
58
59#[cfg(windows)]
60fn compute_effective_path() -> OsString {
61    std::env::var_os("PATH").unwrap_or_default()
62}
63
64#[cfg(not(any(unix, windows)))]
65fn compute_effective_path() -> OsString {
66    std::env::var_os("PATH").unwrap_or_default()
67}
68
69#[cfg(unix)]
70fn compute_effective_path() -> OsString {
71    let current = std::env::var_os("PATH").unwrap_or_default();
72    let home = std::env::var_os("HOME");
73    compute_effective_path_with(
74        &current,
75        home.as_deref(),
76        |dir| dir.is_dir(),
77        probe_login_shell_path,
78    )
79}
80
81#[cfg(unix)]
82fn compute_effective_path_with<D, P>(
83    current: &OsStr,
84    home: Option<&OsStr>,
85    dir_exists: D,
86    mut probe_login_path: P,
87) -> OsString
88where
89    D: FnMut(&Path) -> bool,
90    P: FnMut() -> Option<OsString>,
91{
92    if !path_is_impoverished(current, home, dir_exists) {
93        return current.to_os_string();
94    }
95
96    let Some(login_path) = probe_login_path().filter(|path| login_path_is_acceptable(path)) else {
97        return current.to_os_string();
98    };
99
100    merge_login_and_current_path(&login_path, current)
101}
102
103#[cfg(unix)]
104fn path_is_impoverished<D>(current: &OsStr, home: Option<&OsStr>, mut dir_exists: D) -> bool
105where
106    D: FnMut(&Path) -> bool,
107{
108    let current_entries: Vec<PathBuf> = std::env::split_paths(current).collect();
109    user_standard_path_dirs(home).into_iter().any(|dir| {
110        dir_exists(&dir)
111            && !current_entries
112                .iter()
113                .any(|entry| entry.as_path() == dir.as_path())
114    })
115}
116
117#[cfg(unix)]
118fn user_standard_path_dirs(home: Option<&OsStr>) -> Vec<PathBuf> {
119    let mut dirs = vec![
120        PathBuf::from("/opt/homebrew/bin"),
121        PathBuf::from("/usr/local/bin"),
122    ];
123    if let Some(home) = home {
124        let home = PathBuf::from(home);
125        dirs.push(home.join(".cargo/bin"));
126        dirs.push(home.join(".local/bin"));
127    }
128    dirs
129}
130
131#[cfg(unix)]
132fn probe_login_shell_path() -> Option<OsString> {
133    let candidates = login_shell_candidates();
134    for shell in candidates {
135        let Some(path) = probe_login_shell_path_once(&shell, LOGIN_SHELL_PATH_PROBE_TIMEOUT) else {
136            continue;
137        };
138        if login_path_is_acceptable(&path) {
139            return Some(path);
140        }
141    }
142    None
143}
144
145#[cfg(unix)]
146fn login_shell_candidates() -> Vec<PathBuf> {
147    if let Some(shell) = std::env::var_os("SHELL").filter(|value| !value.is_empty()) {
148        return vec![PathBuf::from(shell)];
149    }
150    vec![PathBuf::from("/bin/zsh"), PathBuf::from("/bin/bash")]
151}
152
153#[cfg(unix)]
154fn probe_login_shell_path_once(shell: &Path, timeout: Duration) -> Option<OsString> {
155    let mut command = Command::new(shell);
156    command
157        .arg("-l")
158        .arg("-c")
159        .arg(r#"printf %s "$PATH""#)
160        .stdin(Stdio::null())
161        .stdout(Stdio::piped())
162        .stderr(Stdio::null());
163    // Run the probe in its own session so the timeout can kill login-shell
164    // startup helpers as well as the shell process itself.
165    unsafe {
166        command.pre_exec(|| {
167            if libc::setsid() == -1 {
168                return Err(std::io::Error::last_os_error());
169            }
170            Ok(())
171        });
172    }
173    let mut child = command.spawn().ok()?;
174
175    let deadline = Instant::now() + timeout;
176    loop {
177        match child.try_wait() {
178            Ok(Some(_)) => {
179                let output = child.wait_with_output().ok()?;
180                return Some(OsString::from_vec(output.stdout));
181            }
182            Ok(None) if Instant::now() >= deadline => {
183                kill_login_shell_probe(&mut child);
184                return None;
185            }
186            Ok(None) => {
187                let remaining = deadline.saturating_duration_since(Instant::now());
188                std::thread::sleep(remaining.min(Duration::from_millis(25)));
189            }
190            Err(_) => {
191                kill_login_shell_probe(&mut child);
192                return None;
193            }
194        }
195    }
196}
197
198#[cfg(unix)]
199fn kill_login_shell_probe(child: &mut std::process::Child) {
200    let pid = child.id() as i32;
201    if pid > 0 {
202        // Negative PID targets the process group created by setsid above.
203        unsafe {
204            let _ = libc::kill(-pid, libc::SIGKILL);
205        }
206    }
207    let _ = child.kill();
208    let _ = child.wait();
209}
210
211#[cfg(unix)]
212fn login_path_is_acceptable(path: &OsStr) -> bool {
213    let bytes = path.as_bytes();
214    if bytes.is_empty()
215        || bytes
216            .iter()
217            .any(|byte| matches!(byte, b'\0' | b'\n' | b'\r'))
218    {
219        return false;
220    }
221
222    bytes
223        .split(|byte| *byte == b':')
224        .all(|entry| entry.first() == Some(&b'/'))
225}
226
227#[cfg(unix)]
228fn merge_login_and_current_path(login_path: &OsStr, current_path: &OsStr) -> OsString {
229    let mut seen = HashSet::new();
230    let mut merged = Vec::new();
231
232    for entry in std::env::split_paths(login_path).chain(std::env::split_paths(current_path)) {
233        if seen.insert(entry.clone()) {
234            merged.push(entry);
235        }
236    }
237
238    std::env::join_paths(merged).unwrap_or_else(|_| current_path.to_os_string())
239}
240
241#[cfg(test)]
242#[cfg(unix)]
243mod tests {
244    use super::*;
245    use std::fs;
246    use std::os::unix::fs::PermissionsExt;
247
248    #[test]
249    fn impoverished_path_merges_login_first_and_keeps_current_entries() {
250        let current = OsStr::new("/usr/bin:/bin:/home/alice/.cargo/bin:/custom/current");
251        let login = OsString::from("/fake/login/bin:/usr/bin:/opt/homebrew/bin");
252
253        let effective = compute_effective_path_with(
254            current,
255            Some(OsStr::new("/home/alice")),
256            |_| true,
257            || Some(login.clone()),
258        );
259
260        assert_eq!(
261            effective,
262            OsString::from(
263                "/fake/login/bin:/usr/bin:/opt/homebrew/bin:/bin:/home/alice/.cargo/bin:/custom/current"
264            )
265        );
266    }
267
268    #[test]
269    fn invalid_probe_paths_are_rejected() {
270        let current = OsStr::new("/usr/bin:/bin");
271        let rejected = vec![
272            OsString::new(),
273            OsString::from("/fake/login/bin\n/usr/bin"),
274            OsString::from("/fake/login/bin:relative/bin"),
275            OsString::from_vec(b"/fake/login/bin\0/usr/bin".to_vec()),
276        ];
277
278        for probe_path in rejected {
279            let effective = compute_effective_path_with(
280                current,
281                Some(OsStr::new("/home/alice")),
282                |_| true,
283                || Some(probe_path.clone()),
284            );
285            assert_eq!(effective, current);
286        }
287    }
288
289    #[test]
290    fn rich_current_path_does_not_probe_login_shell() {
291        let current = OsStr::new(
292            "/opt/homebrew/bin:/usr/local/bin:/home/alice/.cargo/bin:/home/alice/.local/bin:/usr/bin:/bin",
293        );
294
295        let effective = compute_effective_path_with(
296            current,
297            Some(OsStr::new("/home/alice")),
298            |_| true,
299            || panic!("rich PATH must not invoke the login-shell probe"),
300        );
301
302        assert_eq!(effective, current);
303    }
304
305    #[test]
306    fn login_shell_probe_times_out() {
307        let dir = tempfile::tempdir().expect("create tempdir");
308        let shell = dir.path().join("slow-login-shell");
309        fs::write(
310            &shell,
311            "#!/bin/sh\nsleep 10\nprintf '%s' '/fake/login/bin:/usr/bin:/bin'\n",
312        )
313        .expect("write fake shell");
314        let mut permissions = fs::metadata(&shell)
315            .expect("fake shell metadata")
316            .permissions();
317        permissions.set_mode(0o755);
318        fs::set_permissions(&shell, permissions).expect("chmod fake shell");
319
320        let started = Instant::now();
321        let probed = probe_login_shell_path_once(&shell, LOGIN_SHELL_PATH_PROBE_TIMEOUT);
322
323        assert!(probed.is_none());
324        assert!(
325            started.elapsed() < Duration::from_secs(5),
326            "login-shell PATH probe exceeded the 5s test budget"
327        );
328    }
329}