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_current_and_login_path(¤t, &new_path);
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_current_and_login_path(¤t, &memo_path);
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 let Some(login_path) = probe_login_shell_path() {
180 write_login_path_memo(&login_path);
181 merge_current_and_login_path(¤t, &login_path)
182 } else if let Some(memo_path) = read_login_path_memo() {
183 merge_current_and_login_path(¤t, &memo_path)
184 } else {
185 is_fallback = true;
186 last_probe_attempt = Some(Instant::now());
187 current.to_os_string()
188 };
189
190 let enriched = append_missing_standard_dirs(&path, home.as_deref(), |dir| dir.is_dir());
191 let leaked: &'static OsStr = Box::leak(enriched.into_boxed_os_str());
192 *guard = Some(PathState {
193 path: leaked,
194 is_fallback,
195 last_probe_attempt,
196 });
197 leaked
198}
199
200#[cfg(windows)]
201fn compute_effective_path() -> OsString {
202 std::env::var_os("PATH").unwrap_or_default()
203}
204
205#[cfg(not(any(unix, windows)))]
206fn compute_effective_path() -> OsString {
207 std::env::var_os("PATH").unwrap_or_default()
208}
209
210#[cfg(unix)]
212fn core_standard_path_dirs(home: Option<&OsStr>) -> Vec<PathBuf> {
213 let mut dirs = vec![
214 PathBuf::from("/opt/homebrew/bin"),
215 PathBuf::from("/usr/local/bin"),
216 ];
217 if let Some(home) = home {
218 let home = PathBuf::from(home);
219 dirs.push(home.join(".cargo/bin"));
220 dirs.push(home.join(".local/bin"));
221 }
222 dirs
223}
224
225#[cfg(unix)]
228fn user_standard_path_dirs(home: Option<&OsStr>) -> Vec<PathBuf> {
229 let mut dirs = core_standard_path_dirs(home);
230 if let Some(home) = home {
231 let home = PathBuf::from(home);
232 dirs.push(home.join(".bun/bin"));
233 dirs.push(home.join("Library/pnpm"));
234 dirs.push(home.join(".local/share/pnpm"));
235 dirs.push(home.join(".local/share/mise/shims"));
236 dirs.push(home.join(".deno/bin"));
237 dirs.push(home.join(".volta/bin"));
238 }
239 dirs
240}
241
242#[cfg(unix)]
243fn append_missing_standard_dirs<D>(
244 path: &OsStr,
245 home: Option<&OsStr>,
246 mut dir_exists: D,
247) -> OsString
248where
249 D: FnMut(&Path) -> bool,
250{
251 let mut entries: Vec<PathBuf> = std::env::split_paths(path).collect();
252 let mut seen: HashSet<PathBuf> = entries.iter().cloned().collect();
253
254 for dir in user_standard_path_dirs(home) {
255 if dir_exists(&dir) && seen.insert(dir.clone()) {
256 entries.push(dir);
257 }
258 }
259
260 std::env::join_paths(entries).unwrap_or_else(|_| path.to_os_string())
261}
262
263#[cfg(unix)]
264fn probe_login_shell_path() -> Option<OsString> {
265 let candidates = login_shell_candidates();
266 for shell in candidates {
267 let Some(path) = probe_login_shell_path_once(&shell, LOGIN_SHELL_PATH_PROBE_TIMEOUT) else {
268 continue;
269 };
270 if login_path_is_acceptable(&path) {
271 return Some(path);
272 }
273 }
274 None
275}
276
277#[cfg(unix)]
278fn login_shell_candidates() -> Vec<PathBuf> {
279 if cfg!(test) {
280 if let Some(val) = std::env::var_os("AFT_TEST_LOGIN_SHELL_CANDIDATES") {
281 return std::env::split_paths(&val).collect();
282 }
283 }
284 let mut candidates = Vec::new();
285 if let Some(shell) = std::env::var_os("SHELL").filter(|value| !value.is_empty()) {
286 candidates.push(PathBuf::from(shell));
287 }
288 let zsh = PathBuf::from("/bin/zsh");
289 let bash = PathBuf::from("/bin/bash");
290 if !candidates.contains(&zsh) {
291 candidates.push(zsh);
292 }
293 if !candidates.contains(&bash) {
294 candidates.push(bash);
295 }
296 candidates
297}
298
299#[cfg(unix)]
300fn set_nonblocking<F: AsRawFd>(file: &F) -> std::io::Result<()> {
301 let fd = file.as_raw_fd();
302 unsafe {
303 let flags = libc::fcntl(fd, libc::F_GETFL);
304 if flags < 0 {
305 return Err(std::io::Error::last_os_error());
306 }
307 if libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) < 0 {
308 return Err(std::io::Error::last_os_error());
309 }
310 }
311 Ok(())
312}
313
314#[cfg(unix)]
315fn probe_login_shell_path_once(shell: &Path, timeout: Duration) -> Option<OsString> {
316 let mut command = Command::new(shell);
317
318 command
319 .arg(probe_shell_flags(shell))
320 .arg(probe_shell_command(shell))
321 .stdin(Stdio::null())
322 .stdout(Stdio::piped())
323 .stderr(Stdio::null());
324 unsafe {
327 command.pre_exec(|| {
328 if libc::setsid() == -1 {
329 return Err(std::io::Error::last_os_error());
330 }
331 Ok(())
332 });
333 }
334 let mut child = command.spawn().ok()?;
335 let mut stdout = child.stdout.take()?;
336 let _ = set_nonblocking(&stdout);
337 let mut output_bytes = Vec::new();
338 let mut buf = [0u8; 1024];
339
340 let deadline = Instant::now() + timeout;
341 loop {
342 use std::io::Read;
343 match stdout.read(&mut buf) {
344 Ok(0) => {
345 let wait_deadline = Instant::now() + Duration::from_secs(1);
346 loop {
347 match child.try_wait() {
348 Ok(Some(_)) => break,
349 Ok(None) if Instant::now() >= wait_deadline => {
350 kill_login_shell_probe(&mut child);
351 break;
352 }
353 Ok(None) => {
354 std::thread::sleep(Duration::from_millis(10));
355 }
356 Err(_) => {
357 kill_login_shell_probe(&mut child);
358 break;
359 }
360 }
361 }
362 return extract_probe_path(&output_bytes);
363 }
364 Ok(n) => {
365 output_bytes.extend_from_slice(&buf[..n]);
366 }
367 Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
368 }
370 Err(_) => {
371 kill_login_shell_probe(&mut child);
372 return None;
373 }
374 }
375
376 match child.try_wait() {
377 Ok(Some(_)) => {
378 loop {
379 match stdout.read(&mut buf) {
380 Ok(0) => break,
381 Ok(n) => output_bytes.extend_from_slice(&buf[..n]),
382 Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
383 break;
384 }
385 Err(_) => break,
386 }
387 }
388 let _ = child.wait();
389 return extract_probe_path(&output_bytes);
390 }
391 Ok(None) if Instant::now() >= deadline => {
392 kill_login_shell_probe(&mut child);
393 return None;
394 }
395 Ok(None) => {
396 std::thread::sleep(Duration::from_millis(25));
397 }
398 Err(_) => {
399 kill_login_shell_probe(&mut child);
400 return None;
401 }
402 }
403 }
404}
405
406#[cfg(unix)]
407fn probe_shell_flags(shell: &Path) -> &'static str {
408 let name = shell.file_name().and_then(|name| name.to_str());
409 if name.is_some_and(|name| name.eq_ignore_ascii_case("zsh")) {
410 "-lic"
413 } else {
414 "-lc"
417 }
418}
419
420#[cfg(unix)]
421fn probe_shell_command(shell: &Path) -> &'static str {
422 let name = shell.file_name().and_then(|name| name.to_str());
423 if name.is_some_and(|name| name.eq_ignore_ascii_case("fish")) {
424 r#"printf '\n__AFT_PATH_BEGIN__%s__AFT_PATH_END__\n' (string join : $PATH)"#
425 } else {
426 r#"printf '\n__AFT_PATH_BEGIN__%s__AFT_PATH_END__\n' "$PATH""#
427 }
428}
429
430#[cfg(unix)]
431fn extract_probe_path(output: &[u8]) -> Option<OsString> {
432 const BEGIN: &[u8] = b"__AFT_PATH_BEGIN__";
433 const END: &[u8] = b"__AFT_PATH_END__";
434 let begin = output
435 .windows(BEGIN.len())
436 .position(|window| window == BEGIN)?
437 + BEGIN.len();
438 let end = output[begin..]
439 .windows(END.len())
440 .position(|window| window == END)?
441 + begin;
442 Some(OsString::from_vec(output[begin..end].to_vec()))
443}
444
445#[cfg(unix)]
446fn kill_login_shell_probe(child: &mut std::process::Child) {
447 let pid = child.id() as i32;
448 if pid > 0 {
449 unsafe {
451 let _ = libc::kill(-pid, libc::SIGKILL);
452 }
453 }
454 let _ = child.kill();
455 let _ = child.wait();
456}
457
458#[cfg(unix)]
459fn login_path_is_acceptable(path: &OsStr) -> bool {
460 let bytes = path.as_bytes();
461 if bytes.is_empty()
462 || bytes
463 .iter()
464 .any(|byte| matches!(byte, b'\0' | b'\n' | b'\r'))
465 {
466 return false;
467 }
468
469 if bytes.contains(&b' ') {
470 let mut abs_count = 0;
471 for part in bytes.split(|&b| b == b' ') {
472 if part.first() == Some(&b'/') {
473 abs_count += 1;
474 }
475 }
476 if abs_count > 1 {
477 return false;
478 }
479 }
480
481 bytes
482 .split(|byte| *byte == b':')
483 .all(|entry| entry.first() == Some(&b'/'))
484}
485
486#[cfg(unix)]
487fn merge_current_and_login_path(current_path: &OsStr, login_path: &OsStr) -> OsString {
488 let mut seen = HashSet::new();
489 let mut merged = Vec::new();
490
491 for entry in std::env::split_paths(current_path).chain(std::env::split_paths(login_path)) {
494 if seen.insert(entry.clone()) {
495 merged.push(entry);
496 }
497 }
498
499 std::env::join_paths(merged).unwrap_or_else(|_| current_path.to_os_string())
500}
501
502#[cfg(unix)]
503#[derive(Serialize, Deserialize)]
504struct LoginPathMemo {
505 login_path: String,
506}
507
508#[cfg(unix)]
509fn read_login_path_memo() -> Option<OsString> {
510 let memo_path = crate::bash_background::storage_dir(None).join("login-path-memo.json");
511 let content = std::fs::read_to_string(&memo_path).ok()?;
512 let memo: LoginPathMemo = serde_json::from_str(&content).ok()?;
513 Some(OsString::from(memo.login_path))
514}
515
516#[cfg(unix)]
517fn write_login_path_memo(path: &OsStr) {
518 let memo_path = crate::bash_background::storage_dir(None).join("login-path-memo.json");
519 if let Some(parent) = memo_path.parent() {
520 let _ = std::fs::create_dir_all(parent);
521 }
522 let memo = LoginPathMemo {
523 login_path: path.to_string_lossy().into_owned(),
524 };
525 if let Ok(content) = serde_json::to_string(&memo) {
526 let _ = std::fs::write(&memo_path, content);
527 }
528}
529
530#[cfg(test)]
531#[cfg(unix)]
532mod tests {
533 use super::*;
534 use std::fs;
535 use std::os::unix::fs::PermissionsExt;
536
537 struct EnvVarGuard {
538 key: &'static str,
539 old_value: Option<OsString>,
540 }
541
542 impl EnvVarGuard {
543 fn set(key: &'static str, value: &str) -> Self {
544 let old_value = std::env::var_os(key);
545 std::env::set_var(key, value);
546 Self { key, old_value }
547 }
548 }
549
550 impl Drop for EnvVarGuard {
551 fn drop(&mut self) {
552 if let Some(val) = &self.old_value {
553 std::env::set_var(self.key, val);
554 } else {
555 std::env::remove_var(self.key);
556 }
557 }
558 }
559
560 fn write_executable_shim(path: &Path, body: &str) {
561 fs::write(path, body).unwrap();
562 let mut permissions = fs::metadata(path).unwrap().permissions();
563 permissions.set_mode(0o755);
564 fs::set_permissions(path, permissions).unwrap();
565 }
566
567 #[test]
568 fn probe_entries_are_appended_after_current_entries_without_duplicates() {
569 let current = OsStr::new("/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin");
570 let login = OsString::from("/usr/bin:/custom/bin:/opt/homebrew/bin");
571
572 let effective = merge_current_and_login_path(current, &login);
573
574 assert_eq!(
575 effective,
576 OsString::from("/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/custom/bin")
577 );
578 }
579
580 #[test]
581 fn probe_shell_flags_match_shell_startup_conventions() {
582 assert_eq!(probe_shell_flags(Path::new("/bin/zsh")), "-lic");
583 assert_eq!(probe_shell_flags(Path::new("/bin/bash")), "-lc");
584 assert_eq!(
585 probe_shell_flags(Path::new("/opt/homebrew/bin/fish")),
586 "-lc"
587 );
588 }
589
590 #[test]
591 fn marker_extraction_ignores_shell_startup_noise() {
592 let output = b"banner before\n__AFT_PATH_BEGIN__/custom/bin:/usr/bin__AFT_PATH_END__\nbanner after\n";
593
594 assert_eq!(
595 extract_probe_path(output),
596 Some(OsString::from("/custom/bin:/usr/bin"))
597 );
598 }
599
600 #[test]
601 fn zsh_probe_uses_interactive_login_and_reads_zshrc() {
602 let _guard = crate::test_env::process_env_lock();
603 let dir = tempfile::tempdir().unwrap();
604 let home = dir.path().join("home");
605 let custom_bin = home.join(".custom/bin");
606 let shell = dir.path().join("zsh");
607 fs::create_dir_all(&custom_bin).unwrap();
608 fs::write(
609 home.join(".zshrc"),
610 format!(
611 "printf 'banner before\n'; export PATH=\"$PATH:{}\"; printf 'banner after\n'\n",
612 custom_bin.display()
613 ),
614 )
615 .unwrap();
616 write_executable_shim(
617 &shell,
618 r#"#!/bin/sh
619if [ "$1" != '-lic' ]; then
620 exit 64
621fi
622if [ -f "$ZDOTDIR/.zshrc" ]; then
623 . "$ZDOTDIR/.zshrc"
624fi
625eval "$2"
626"#,
627 );
628
629 let _path_guard = EnvVarGuard::set("PATH", "/usr/bin:/bin");
630 let _home_guard = EnvVarGuard::set("HOME", home.to_str().unwrap());
631 let _zdotdir_guard = EnvVarGuard::set("ZDOTDIR", home.to_str().unwrap());
632 let probed = probe_login_shell_path_once(&shell, LOGIN_SHELL_PATH_PROBE_TIMEOUT);
633
634 assert_eq!(
635 probed,
636 Some(OsString::from(format!(
637 "/usr/bin:/bin:{}",
638 custom_bin.display()
639 )))
640 );
641 }
642
643 #[test]
644 fn invalid_probe_paths_are_rejected() {
645 let rejected = vec![
646 OsString::new(),
647 OsString::from("/fake/login/bin\n/usr/bin"),
648 OsString::from("/fake/login/bin:relative/bin"),
649 OsString::from_vec(b"/fake/login/bin\0/usr/bin".to_vec()),
650 OsString::from("/usr/bin /bin /opt/homebrew/bin"), ];
652
653 for probe_path in rejected {
654 assert!(!login_path_is_acceptable(&probe_path));
655 }
656 }
657
658 #[test]
659 fn login_shell_probe_times_out() {
660 let dir = tempfile::tempdir().expect("create tempdir");
661 let shell = dir.path().join("slow-login-shell");
662 fs::write(
663 &shell,
664 "#!/bin/sh\nsleep 10\nprintf '%s' '/fake/login/bin:/usr/bin:/bin'\n",
665 )
666 .expect("write fake shell");
667 let mut permissions = fs::metadata(&shell)
668 .expect("fake shell metadata")
669 .permissions();
670 permissions.set_mode(0o755);
671 fs::set_permissions(&shell, permissions).expect("chmod fake shell");
672
673 let started = Instant::now();
674 let probed = probe_login_shell_path_once(&shell, LOGIN_SHELL_PATH_PROBE_TIMEOUT);
675
676 assert!(probed.is_none());
677 assert!(
678 started.elapsed() < Duration::from_secs(5),
679 "login-shell PATH probe exceeded the 5s test budget"
680 );
681 }
682
683 #[test]
684 fn test_login_shell_candidates_includes_fallbacks() {
685 let _guard = crate::test_env::process_env_lock();
686 let _shell_guard = EnvVarGuard::set("SHELL", "/opt/zerobrew/bin/fish");
687 let candidates = login_shell_candidates();
688 assert_eq!(candidates[0], PathBuf::from("/opt/zerobrew/bin/fish"));
689 assert!(candidates.contains(&PathBuf::from("/bin/zsh")));
690 assert!(candidates.contains(&PathBuf::from("/bin/bash")));
691 }
692
693 #[test]
694 fn test_memo_written_and_read() {
695 let _guard = crate::test_env::process_env_lock();
696 let temp = tempfile::tempdir().unwrap();
697 let _cache_guard = EnvVarGuard::set("AFT_CACHE_DIR", temp.path().to_str().unwrap());
698
699 let test_path = OsStr::new("/opt/homebrew/bin:/usr/bin:/bin");
700 write_login_path_memo(test_path);
701
702 let read_path = read_login_path_memo().unwrap();
703 assert_eq!(read_path, test_path);
704 }
705
706 #[test]
707 fn probe_failure_falls_back_to_current_path_and_appends_standard_dirs() {
708 let home = Path::new("/home/alice");
709 let current = OsStr::new("/usr/bin:/bin");
710 let missing_shell = Path::new("/nonexistent/shell");
711
712 assert!(probe_login_shell_path_once(missing_shell, Duration::from_millis(10)).is_none());
713
714 let enriched = append_missing_standard_dirs(current, Some(home.as_os_str()), |dir| {
715 dir == Path::new("/home/alice/.bun/bin")
716 });
717 assert_eq!(
718 enriched,
719 OsString::from("/usr/bin:/bin:/home/alice/.bun/bin")
720 );
721 }
722
723 #[test]
724 fn test_append_missing_standard_dirs() {
725 let home = OsStr::new("/home/alice");
726 let path = OsStr::new("/usr/bin:/bin");
727
728 let dir_exists = |dir: &Path| dir == Path::new("/home/alice/.bun/bin");
730
731 let enriched = append_missing_standard_dirs(path, Some(home), dir_exists);
732 assert_eq!(
733 enriched,
734 OsString::from("/usr/bin:/bin:/home/alice/.bun/bin")
735 );
736 }
737
738 #[test]
739 fn test_append_missing_standard_dirs_dedup_and_order() {
740 let home = OsStr::new("/home/alice");
741 let path = OsStr::new("/home/alice/.bun/bin:/usr/bin:/bin");
743
744 let dir_exists = |dir: &Path| {
745 dir == Path::new("/home/alice/.bun/bin") || dir == Path::new("/opt/homebrew/bin")
746 };
747
748 let enriched = append_missing_standard_dirs(path, Some(home), dir_exists);
749 assert_eq!(
751 enriched,
752 OsString::from("/home/alice/.bun/bin:/usr/bin:/bin:/opt/homebrew/bin")
753 );
754 }
755}