1use 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
30pub 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
49pub 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 ¤t,
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 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 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}