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::io::AsRawFd;
24#[cfg(unix)]
25use std::os::unix::process::CommandExt;
26
27#[cfg(unix)]
28use serde::{Deserialize, Serialize};
29
30#[cfg(not(unix))]
31static EFFECTIVE_PATH: OnceLock<OsString> = OnceLock::new();
32
33#[cfg(unix)]
34#[derive(Clone, Debug)]
35struct PathState {
36 path: &'static OsStr,
37 is_fallback: bool,
38 last_probe_attempt: Option<Instant>,
39}
40
41#[cfg(unix)]
42static EFFECTIVE_PATH_STATE: std::sync::Mutex<Option<PathState>> = std::sync::Mutex::new(None);
43
44#[cfg(unix)]
45const LOGIN_SHELL_PATH_PROBE_TIMEOUT: Duration = Duration::from_secs(3);
46
47pub fn initialize_process_path() -> &'static OsStr {
54 let path = effective_path();
55
56 #[cfg(unix)]
57 {
58 if path != OsStr::new("") && std::env::var_os("PATH").as_deref() != Some(path) {
59 std::env::set_var("PATH", path);
60 }
61 }
62
63 path
64}
65
66#[cfg(unix)]
68pub fn new_command<S: AsRef<OsStr>>(program: S) -> std::process::Command {
69 let mut cmd = std::process::Command::new(program);
70 cmd.env("PATH", effective_path());
71 cmd
72}
73
74#[cfg(not(unix))]
77pub fn new_command<S: AsRef<OsStr>>(program: S) -> std::process::Command {
78 std::process::Command::new(program)
79}
80
81#[cfg(not(unix))]
86pub fn effective_path() -> &'static OsStr {
87 EFFECTIVE_PATH
88 .get_or_init(compute_effective_path)
89 .as_os_str()
90}
91
92#[cfg(unix)]
93pub fn effective_path() -> &'static OsStr {
94 if std::env::var_os("AFT_TEST_RAW_PATH").is_some_and(|v| v != "0" && !v.is_empty()) {
101 static RAW: OnceLock<OsString> = OnceLock::new();
102 return RAW
103 .get_or_init(|| std::env::var_os("PATH").unwrap_or_default())
104 .as_os_str();
105 }
106 let mut guard = EFFECTIVE_PATH_STATE
107 .lock()
108 .unwrap_or_else(|e| e.into_inner());
109 let state_opt = guard.clone();
110 if let Some(state) = state_opt {
111 if state.is_fallback {
112 let now = Instant::now();
113 let should_retry = state
114 .last_probe_attempt
115 .map(|last| now.duration_since(last) >= Duration::from_secs(60))
116 .unwrap_or(true);
117
118 if should_retry {
119 let last_attempt = Some(now);
120 let mut temp_state = state.clone();
121 temp_state.last_probe_attempt = last_attempt;
122 *guard = Some(temp_state);
123 drop(guard);
124
125 let new_path_opt = probe_login_shell_path();
126
127 let mut guard = EFFECTIVE_PATH_STATE
128 .lock()
129 .unwrap_or_else(|e| e.into_inner());
130 if let Some(new_path) = new_path_opt {
131 write_login_path_memo(&new_path);
132 let current = std::env::var_os("PATH").unwrap_or_default();
133 let merged = merge_login_and_current_path(&new_path, ¤t);
134 let home = std::env::var_os("HOME");
135 let enriched =
136 append_missing_standard_dirs(&merged, home.as_deref(), |dir| dir.is_dir());
137 let leaked: &'static OsStr = Box::leak(enriched.into_boxed_os_str());
138 let new_state = PathState {
139 path: leaked,
140 is_fallback: false,
141 last_probe_attempt: None,
142 };
143 *guard = Some(new_state);
144 return leaked;
145 } else {
146 let memo_path_opt = read_login_path_memo();
147 if let Some(mut current_state) = guard.clone() {
148 current_state.last_probe_attempt = Some(Instant::now());
149 if let Some(memo_path) = memo_path_opt {
150 let current = std::env::var_os("PATH").unwrap_or_default();
151 let merged = merge_login_and_current_path(&memo_path, ¤t);
152 let home = std::env::var_os("HOME");
153 let enriched =
154 append_missing_standard_dirs(&merged, home.as_deref(), |dir| {
155 dir.is_dir()
156 });
157 let leaked: &'static OsStr = Box::leak(enriched.into_boxed_os_str());
158 current_state.path = leaked;
159 current_state.is_fallback = false;
160 }
161 *guard = Some(current_state);
162 return guard.as_ref().unwrap().path;
163 }
164 }
165 }
166 }
167 return state.path;
168 }
169
170 let current = std::env::var_os("PATH").unwrap_or_default();
171 let home = std::env::var_os("HOME");
172
173 let mut is_fallback = false;
174 let mut last_probe_attempt = None;
175
176 let path = if !path_is_impoverished(¤t, home.as_deref(), |dir| dir.is_dir()) {
177 current.to_os_string()
178 } else {
179 if let Some(login_path) = probe_login_shell_path() {
180 write_login_path_memo(&login_path);
181 merge_login_and_current_path(&login_path, ¤t)
182 } else if let Some(memo_path) = read_login_path_memo() {
183 merge_login_and_current_path(&memo_path, ¤t)
184 } else {
185 is_fallback = true;
186 last_probe_attempt = Some(Instant::now());
187 current.to_os_string()
188 }
189 };
190
191 let enriched = append_missing_standard_dirs(&path, home.as_deref(), |dir| dir.is_dir());
192 let leaked: &'static OsStr = Box::leak(enriched.into_boxed_os_str());
193 *guard = Some(PathState {
194 path: leaked,
195 is_fallback,
196 last_probe_attempt,
197 });
198 leaked
199}
200
201#[cfg(windows)]
202fn compute_effective_path() -> OsString {
203 std::env::var_os("PATH").unwrap_or_default()
204}
205
206#[cfg(not(any(unix, windows)))]
207fn compute_effective_path() -> OsString {
208 std::env::var_os("PATH").unwrap_or_default()
209}
210
211#[cfg(unix)]
212fn path_is_impoverished<D>(current: &OsStr, home: Option<&OsStr>, mut dir_exists: D) -> bool
213where
214 D: FnMut(&Path) -> bool,
215{
216 let current_entries: Vec<PathBuf> = std::env::split_paths(current).collect();
217 core_standard_path_dirs(home).into_iter().any(|dir| {
218 dir_exists(&dir)
219 && !current_entries
220 .iter()
221 .any(|entry| entry.as_path() == dir.as_path())
222 })
223}
224
225#[cfg(unix)]
231fn core_standard_path_dirs(home: Option<&OsStr>) -> Vec<PathBuf> {
232 let mut dirs = vec![
233 PathBuf::from("/opt/homebrew/bin"),
234 PathBuf::from("/usr/local/bin"),
235 ];
236 if let Some(home) = home {
237 let home = PathBuf::from(home);
238 dirs.push(home.join(".cargo/bin"));
239 dirs.push(home.join(".local/bin"));
240 }
241 dirs
242}
243
244#[cfg(unix)]
248fn user_standard_path_dirs(home: Option<&OsStr>) -> Vec<PathBuf> {
249 let mut dirs = core_standard_path_dirs(home);
250 if let Some(home) = home {
251 let home = PathBuf::from(home);
252 dirs.push(home.join(".bun/bin"));
253 dirs.push(home.join("Library/pnpm"));
254 dirs.push(home.join(".local/share/pnpm"));
255 dirs.push(home.join(".local/share/mise/shims"));
256 dirs.push(home.join(".deno/bin"));
257 dirs.push(home.join(".volta/bin"));
258 }
259 dirs
260}
261
262#[cfg(unix)]
263fn append_missing_standard_dirs<D>(
264 path: &OsStr,
265 home: Option<&OsStr>,
266 mut dir_exists: D,
267) -> OsString
268where
269 D: FnMut(&Path) -> bool,
270{
271 let mut entries: Vec<PathBuf> = std::env::split_paths(path).collect();
272 let mut seen: HashSet<PathBuf> = entries.iter().cloned().collect();
273
274 for dir in user_standard_path_dirs(home) {
275 if dir_exists(&dir) && seen.insert(dir.clone()) {
276 entries.push(dir);
277 }
278 }
279
280 std::env::join_paths(entries).unwrap_or_else(|_| path.to_os_string())
281}
282
283#[cfg(unix)]
284fn probe_login_shell_path() -> Option<OsString> {
285 let candidates = login_shell_candidates();
286 for shell in candidates {
287 let Some(path) = probe_login_shell_path_once(&shell, LOGIN_SHELL_PATH_PROBE_TIMEOUT) else {
288 continue;
289 };
290 if login_path_is_acceptable(&path) {
291 return Some(path);
292 }
293 }
294 None
295}
296
297#[cfg(unix)]
298fn login_shell_candidates() -> Vec<PathBuf> {
299 if cfg!(test) {
300 if let Some(val) = std::env::var_os("AFT_TEST_LOGIN_SHELL_CANDIDATES") {
301 return std::env::split_paths(&val).collect();
302 }
303 }
304 let mut candidates = Vec::new();
305 if let Some(shell) = std::env::var_os("SHELL").filter(|value| !value.is_empty()) {
306 candidates.push(PathBuf::from(shell));
307 }
308 let zsh = PathBuf::from("/bin/zsh");
309 let bash = PathBuf::from("/bin/bash");
310 if !candidates.contains(&zsh) {
311 candidates.push(zsh);
312 }
313 if !candidates.contains(&bash) {
314 candidates.push(bash);
315 }
316 candidates
317}
318
319#[cfg(unix)]
320fn set_nonblocking<F: AsRawFd>(file: &F) -> std::io::Result<()> {
321 let fd = file.as_raw_fd();
322 unsafe {
323 let flags = libc::fcntl(fd, libc::F_GETFL);
324 if flags < 0 {
325 return Err(std::io::Error::last_os_error());
326 }
327 if libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) < 0 {
328 return Err(std::io::Error::last_os_error());
329 }
330 }
331 Ok(())
332}
333
334#[cfg(unix)]
335fn probe_login_shell_path_once(shell: &Path, timeout: Duration) -> Option<OsString> {
336 let mut command = Command::new(shell);
337 let is_fish = shell
338 .file_name()
339 .and_then(|n| n.to_str())
340 .map(|s| s.eq_ignore_ascii_case("fish"))
341 .unwrap_or(false);
342
343 let cmd_str = if is_fish {
344 r#"printf %s (string join : $PATH)"#
345 } else {
346 r#"printf %s "$PATH""#
347 };
348
349 command
350 .arg("-l")
351 .arg("-c")
352 .arg(cmd_str)
353 .stdin(Stdio::null())
354 .stdout(Stdio::piped())
355 .stderr(Stdio::null());
356 unsafe {
359 command.pre_exec(|| {
360 if libc::setsid() == -1 {
361 return Err(std::io::Error::last_os_error());
362 }
363 Ok(())
364 });
365 }
366 let mut child = command.spawn().ok()?;
367 let mut stdout = child.stdout.take()?;
368 let _ = set_nonblocking(&stdout);
369 let mut output_bytes = Vec::new();
370 let mut buf = [0u8; 1024];
371
372 let deadline = Instant::now() + timeout;
373 loop {
374 use std::io::Read;
375 match stdout.read(&mut buf) {
376 Ok(0) => {
377 let wait_deadline = Instant::now() + Duration::from_secs(1);
378 loop {
379 match child.try_wait() {
380 Ok(Some(_)) => break,
381 Ok(None) if Instant::now() >= wait_deadline => {
382 kill_login_shell_probe(&mut child);
383 break;
384 }
385 Ok(None) => {
386 std::thread::sleep(Duration::from_millis(10));
387 }
388 Err(_) => {
389 kill_login_shell_probe(&mut child);
390 break;
391 }
392 }
393 }
394 return Some(OsString::from_vec(output_bytes));
395 }
396 Ok(n) => {
397 output_bytes.extend_from_slice(&buf[..n]);
398 }
399 Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
400 }
402 Err(_) => {
403 kill_login_shell_probe(&mut child);
404 return None;
405 }
406 }
407
408 match child.try_wait() {
409 Ok(Some(_)) => {
410 loop {
411 match stdout.read(&mut buf) {
412 Ok(0) => break,
413 Ok(n) => output_bytes.extend_from_slice(&buf[..n]),
414 Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
415 break;
416 }
417 Err(_) => break,
418 }
419 }
420 let _ = child.wait();
421 return Some(OsString::from_vec(output_bytes));
422 }
423 Ok(None) if Instant::now() >= deadline => {
424 kill_login_shell_probe(&mut child);
425 return None;
426 }
427 Ok(None) => {
428 std::thread::sleep(Duration::from_millis(25));
429 }
430 Err(_) => {
431 kill_login_shell_probe(&mut child);
432 return None;
433 }
434 }
435 }
436}
437
438#[cfg(unix)]
439fn kill_login_shell_probe(child: &mut std::process::Child) {
440 let pid = child.id() as i32;
441 if pid > 0 {
442 unsafe {
444 let _ = libc::kill(-pid, libc::SIGKILL);
445 }
446 }
447 let _ = child.kill();
448 let _ = child.wait();
449}
450
451#[cfg(unix)]
452fn login_path_is_acceptable(path: &OsStr) -> bool {
453 let bytes = path.as_bytes();
454 if bytes.is_empty()
455 || bytes
456 .iter()
457 .any(|byte| matches!(byte, b'\0' | b'\n' | b'\r'))
458 {
459 return false;
460 }
461
462 if bytes.contains(&b' ') {
463 let mut abs_count = 0;
464 for part in bytes.split(|&b| b == b' ') {
465 if part.first() == Some(&b'/') {
466 abs_count += 1;
467 }
468 }
469 if abs_count > 1 {
470 return false;
471 }
472 }
473
474 bytes
475 .split(|byte| *byte == b':')
476 .all(|entry| entry.first() == Some(&b'/'))
477}
478
479#[cfg(unix)]
480fn merge_login_and_current_path(login_path: &OsStr, current_path: &OsStr) -> OsString {
481 let mut seen = HashSet::new();
482 let mut merged = Vec::new();
483
484 for entry in std::env::split_paths(login_path).chain(std::env::split_paths(current_path)) {
485 if seen.insert(entry.clone()) {
486 merged.push(entry);
487 }
488 }
489
490 std::env::join_paths(merged).unwrap_or_else(|_| current_path.to_os_string())
491}
492
493#[cfg(unix)]
494#[derive(Serialize, Deserialize)]
495struct LoginPathMemo {
496 login_path: String,
497}
498
499#[cfg(unix)]
500fn read_login_path_memo() -> Option<OsString> {
501 let memo_path = crate::bash_background::storage_dir(None).join("login-path-memo.json");
502 let content = std::fs::read_to_string(&memo_path).ok()?;
503 let memo: LoginPathMemo = serde_json::from_str(&content).ok()?;
504 Some(OsString::from(memo.login_path))
505}
506
507#[cfg(unix)]
508fn write_login_path_memo(path: &OsStr) {
509 let memo_path = crate::bash_background::storage_dir(None).join("login-path-memo.json");
510 if let Some(parent) = memo_path.parent() {
511 let _ = std::fs::create_dir_all(parent);
512 }
513 let memo = LoginPathMemo {
514 login_path: path.to_string_lossy().into_owned(),
515 };
516 if let Ok(content) = serde_json::to_string(&memo) {
517 let _ = std::fs::write(&memo_path, content);
518 }
519}
520
521#[cfg(test)]
522#[cfg(unix)]
523mod tests {
524 use super::*;
525 use std::fs;
526 use std::os::unix::fs::PermissionsExt;
527
528 struct EnvVarGuard {
529 key: &'static str,
530 old_value: Option<OsString>,
531 }
532
533 impl EnvVarGuard {
534 fn set(key: &'static str, value: &str) -> Self {
535 let old_value = std::env::var_os(key);
536 std::env::set_var(key, value);
537 Self { key, old_value }
538 }
539 }
540
541 impl Drop for EnvVarGuard {
542 fn drop(&mut self) {
543 if let Some(val) = &self.old_value {
544 std::env::set_var(self.key, val);
545 } else {
546 std::env::remove_var(self.key);
547 }
548 }
549 }
550
551 fn reset_effective_path_state() {
552 let mut guard = EFFECTIVE_PATH_STATE
553 .lock()
554 .unwrap_or_else(|e| e.into_inner());
555 *guard = None;
556 }
557
558 #[test]
559 fn impoverished_path_merges_login_first_and_keeps_current_entries() {
560 let current = OsStr::new("/usr/bin:/bin:/home/alice/.cargo/bin:/custom/current");
561 let login = OsString::from("/fake/login/bin:/usr/bin:/opt/homebrew/bin");
562
563 let mut dir_exists = |_dir: &Path| true;
564 let probe_login_path = || Some(login.clone());
565
566 if !path_is_impoverished(current, Some(OsStr::new("/home/alice")), &mut dir_exists) {
567 panic!("should be impoverished");
568 }
569
570 let login_path = probe_login_path()
571 .filter(|path| login_path_is_acceptable(path))
572 .unwrap();
573 let effective = merge_login_and_current_path(&login_path, current);
574
575 assert_eq!(
576 effective,
577 OsString::from(
578 "/fake/login/bin:/usr/bin:/opt/homebrew/bin:/bin:/home/alice/.cargo/bin:/custom/current"
579 )
580 );
581 }
582
583 #[test]
584 fn invalid_probe_paths_are_rejected() {
585 let rejected = vec![
586 OsString::new(),
587 OsString::from("/fake/login/bin\n/usr/bin"),
588 OsString::from("/fake/login/bin:relative/bin"),
589 OsString::from_vec(b"/fake/login/bin\0/usr/bin".to_vec()),
590 OsString::from("/usr/bin /bin /opt/homebrew/bin"), ];
592
593 for probe_path in rejected {
594 assert!(!login_path_is_acceptable(&probe_path));
595 }
596 }
597
598 #[test]
599 fn rich_current_path_does_not_probe_login_shell() {
600 let current = OsStr::new(
601 "/opt/homebrew/bin:/usr/local/bin:/home/alice/.cargo/bin:/home/alice/.local/bin:/usr/bin:/bin",
602 );
603
604 assert!(!path_is_impoverished(
605 current,
606 Some(OsStr::new("/home/alice")),
607 |_| true
608 ));
609 }
610
611 #[test]
612 fn login_shell_probe_times_out() {
613 let dir = tempfile::tempdir().expect("create tempdir");
614 let shell = dir.path().join("slow-login-shell");
615 fs::write(
616 &shell,
617 "#!/bin/sh\nsleep 10\nprintf '%s' '/fake/login/bin:/usr/bin:/bin'\n",
618 )
619 .expect("write fake shell");
620 let mut permissions = fs::metadata(&shell)
621 .expect("fake shell metadata")
622 .permissions();
623 permissions.set_mode(0o755);
624 fs::set_permissions(&shell, permissions).expect("chmod fake shell");
625
626 let started = Instant::now();
627 let probed = probe_login_shell_path_once(&shell, LOGIN_SHELL_PATH_PROBE_TIMEOUT);
628
629 assert!(probed.is_none());
630 assert!(
631 started.elapsed() < Duration::from_secs(5),
632 "login-shell PATH probe exceeded the 5s test budget"
633 );
634 }
635
636 #[test]
637 fn test_login_shell_candidates_includes_fallbacks() {
638 let _guard = crate::test_env::process_env_lock();
639 let _shell_guard = EnvVarGuard::set("SHELL", "/opt/zerobrew/bin/fish");
640 let candidates = login_shell_candidates();
641 assert_eq!(candidates[0], PathBuf::from("/opt/zerobrew/bin/fish"));
642 assert!(candidates.contains(&PathBuf::from("/bin/zsh")));
643 assert!(candidates.contains(&PathBuf::from("/bin/bash")));
644 }
645
646 #[test]
647 fn test_memo_written_and_read() {
648 let _guard = crate::test_env::process_env_lock();
649 let temp = tempfile::tempdir().unwrap();
650 let _cache_guard = EnvVarGuard::set("AFT_CACHE_DIR", temp.path().to_str().unwrap());
651
652 let test_path = OsStr::new("/opt/homebrew/bin:/usr/bin:/bin");
653 write_login_path_memo(test_path);
654
655 let read_path = read_login_path_memo().unwrap();
656 assert_eq!(read_path, test_path);
657 }
658
659 #[test]
660 fn test_bounded_re_probe() {
661 let _guard = crate::test_env::process_env_lock();
662 reset_effective_path_state();
663
664 let temp = tempfile::tempdir().unwrap();
665 let _cache_guard = EnvVarGuard::set("AFT_CACHE_DIR", temp.path().to_str().unwrap());
666
667 let _path_guard = EnvVarGuard::set("PATH", "/usr/bin:/bin");
669
670 let _shell_guard = EnvVarGuard::set("SHELL", "/nonexistent/shell");
672
673 let _candidates_guard =
675 EnvVarGuard::set("AFT_TEST_LOGIN_SHELL_CANDIDATES", "/nonexistent/shell");
676
677 let path1 = effective_path();
681 assert!(path1.to_string_lossy().starts_with("/usr/bin:/bin"));
682
683 {
684 let guard = EFFECTIVE_PATH_STATE
685 .lock()
686 .unwrap_or_else(|e| e.into_inner());
687 let state = guard.as_ref().unwrap();
688 assert!(state.is_fallback);
689 assert!(state.last_probe_attempt.is_some());
690 }
691
692 let path2 = effective_path();
694 assert_eq!(path2, path1);
695
696 {
698 let mut guard = EFFECTIVE_PATH_STATE
699 .lock()
700 .unwrap_or_else(|e| e.into_inner());
701 let state = guard.as_mut().unwrap();
702 state.last_probe_attempt = Some(Instant::now() - Duration::from_secs(61));
703 }
704
705 write_login_path_memo(OsStr::new("/opt/homebrew/bin:/usr/bin:/bin"));
707
708 let path3 = effective_path();
711 assert!(path3.to_string_lossy().contains("/opt/homebrew/bin"));
712 }
713
714 #[test]
715 fn test_append_missing_standard_dirs() {
716 let home = OsStr::new("/home/alice");
717 let path = OsStr::new("/usr/bin:/bin");
718
719 let dir_exists = |dir: &Path| dir == Path::new("/home/alice/.bun/bin");
721
722 let enriched = append_missing_standard_dirs(path, Some(home), dir_exists);
723 assert_eq!(
724 enriched,
725 OsString::from("/usr/bin:/bin:/home/alice/.bun/bin")
726 );
727 }
728
729 #[test]
730 fn test_append_missing_standard_dirs_dedup_and_order() {
731 let home = OsStr::new("/home/alice");
732 let path = OsStr::new("/home/alice/.bun/bin:/usr/bin:/bin");
734
735 let dir_exists = |dir: &Path| {
736 dir == Path::new("/home/alice/.bun/bin") || dir == Path::new("/opt/homebrew/bin")
737 };
738
739 let enriched = append_missing_standard_dirs(path, Some(home), dir_exists);
740 assert_eq!(
742 enriched,
743 OsString::from("/home/alice/.bun/bin:/usr/bin:/bin:/opt/homebrew/bin")
744 );
745 }
746}