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, SystemTime, UNIX_EPOCH};
19
20#[cfg(unix)]
21use std::os::unix::ffi::{OsStrExt, OsStringExt};
22#[cfg(unix)]
23use std::os::unix::fs::MetadataExt;
24#[cfg(unix)]
25use std::os::unix::io::AsRawFd;
26#[cfg(unix)]
27use std::os::unix::process::CommandExt;
28
29#[cfg(unix)]
30use serde::{Deserialize, Serialize};
31
32#[cfg(not(unix))]
33static EFFECTIVE_PATH: OnceLock<OsString> = OnceLock::new();
34
35#[cfg(unix)]
36#[derive(Clone, Debug)]
37struct PathState {
38 path: &'static OsStr,
39 source: ProbeSource,
40 shell: String,
41 elapsed: Duration,
42 cache_path: PathBuf,
43 refresh_started: bool,
44 log_emitted: bool,
45}
46
47#[cfg(unix)]
48#[derive(Clone, Copy, Debug, Eq, PartialEq)]
49enum ProbeSource {
50 Cache,
51 Probe,
52 Timeout,
53}
54
55#[cfg(unix)]
56static EFFECTIVE_PATH_STATE: std::sync::Mutex<Option<PathState>> = std::sync::Mutex::new(None);
57
58#[cfg(unix)]
59const LOGIN_SHELL_PATH_PROBE_TIMEOUT: Duration = Duration::from_secs(3);
60#[cfg(unix)]
61const LOGIN_SHELL_PATH_PROBE_TOTAL_BUDGET: Duration = Duration::from_secs(4);
62#[cfg(unix)]
63const EFFECTIVE_PATH_CACHE_SCHEMA: u32 = 1;
64
65pub fn initialize_process_path() -> &'static OsStr {
72 let path = effective_path();
73
74 #[cfg(unix)]
75 {
76 if path != OsStr::new("") && std::env::var_os("PATH").as_deref() != Some(path) {
77 std::env::set_var("PATH", path);
78 }
79 spawn_cached_path_refresh();
80 }
81
82 path
83}
84
85pub fn log_startup_probe_result() {
90 #[cfg(unix)]
91 {
92 let mut guard = EFFECTIVE_PATH_STATE
93 .lock()
94 .unwrap_or_else(|error| error.into_inner());
95 let Some(state) = guard.as_mut() else {
96 return;
97 };
98 if state.log_emitted {
99 return;
100 }
101 state.log_emitted = true;
102 let source = match state.source {
103 ProbeSource::Cache => "cache",
104 ProbeSource::Probe => "probe",
105 ProbeSource::Timeout => "timeout",
106 };
107 log::info!(
108 "login-shell PATH probe: source={source} shell={} elapsed_ms={}",
109 state.shell,
110 state.elapsed.as_millis()
111 );
112 }
113}
114
115#[cfg(unix)]
117pub fn new_command<S: AsRef<OsStr>>(program: S) -> std::process::Command {
118 let mut cmd = std::process::Command::new(program);
119 cmd.env("PATH", effective_path());
120 cmd
121}
122
123#[cfg(not(unix))]
126pub fn new_command<S: AsRef<OsStr>>(program: S) -> std::process::Command {
127 std::process::Command::new(program)
128}
129
130#[cfg(not(unix))]
135pub fn effective_path() -> &'static OsStr {
136 EFFECTIVE_PATH
137 .get_or_init(compute_effective_path)
138 .as_os_str()
139}
140
141#[cfg(unix)]
142pub fn effective_path() -> &'static OsStr {
143 if std::env::var_os("AFT_TEST_RAW_PATH").is_some_and(|value| value != "0" && !value.is_empty())
150 {
151 static RAW: OnceLock<OsString> = OnceLock::new();
152 return RAW
153 .get_or_init(|| std::env::var_os("PATH").unwrap_or_default())
154 .as_os_str();
155 }
156
157 let mut guard = EFFECTIVE_PATH_STATE
158 .lock()
159 .unwrap_or_else(|error| error.into_inner());
160 if let Some(state) = guard.as_ref() {
161 return state.path;
162 }
163
164 let started = Instant::now();
165 let current = std::env::var_os("PATH").unwrap_or_default();
166 let home = crate::environment::non_empty_os_var("HOME");
167 let cache_path = crate::bash_background::storage_dir(None).join("effective-path.json");
171 let candidates = login_shell_candidates();
172
173 let (login_path, source, shell) = read_effective_path_cache(&cache_path)
174 .filter(|cache| cache_matches_current_shell(cache, &candidates))
175 .map(|cache| {
176 (
177 cache.path.map(OsString::from),
178 ProbeSource::Cache,
179 cache.shell,
180 )
181 })
182 .unwrap_or_else(|| {
183 let result = probe_login_shell_path();
184 let cache = EffectivePathCache {
185 schema: EFFECTIVE_PATH_CACHE_SCHEMA,
186 shell: result.shell.to_string_lossy().into_owned(),
187 path: result
188 .path
189 .as_ref()
190 .map(|path| path.to_string_lossy().into_owned()),
191 probed_at_unix: unix_timestamp_secs(),
192 inputs: shell_startup_inputs(&result.shell),
193 };
194 let _ = write_effective_path_cache(&cache_path, &cache);
195 let source = if result.path.is_some() {
196 ProbeSource::Probe
197 } else {
198 ProbeSource::Timeout
199 };
200 (result.path, source, cache.shell)
201 });
202
203 let merged = login_path
204 .as_deref()
205 .map(|path| merge_current_and_login_path(¤t, path))
206 .unwrap_or_else(|| current.clone());
207 let enriched = append_missing_standard_dirs(&merged, home.as_deref(), |dir| dir.is_dir());
208 let path = Box::leak(enriched.into_boxed_os_str());
209 *guard = Some(PathState {
210 path,
211 source,
212 shell,
213 elapsed: started.elapsed(),
214 cache_path,
215 refresh_started: false,
216 log_emitted: false,
217 });
218 path
219}
220
221#[cfg(windows)]
222fn compute_effective_path() -> OsString {
223 std::env::var_os("PATH").unwrap_or_default()
224}
225
226#[cfg(not(any(unix, windows)))]
227fn compute_effective_path() -> OsString {
228 std::env::var_os("PATH").unwrap_or_default()
229}
230
231#[cfg(unix)]
233fn core_standard_path_dirs(home: Option<&OsStr>) -> Vec<PathBuf> {
234 let mut dirs = vec![
235 PathBuf::from("/opt/homebrew/bin"),
236 PathBuf::from("/usr/local/bin"),
237 ];
238 if let Some(home) = home {
239 let home = PathBuf::from(home);
240 dirs.push(home.join(".cargo/bin"));
241 dirs.push(home.join(".local/bin"));
242 }
243 dirs
244}
245
246#[cfg(unix)]
249fn user_standard_path_dirs(home: Option<&OsStr>) -> Vec<PathBuf> {
250 let mut dirs = core_standard_path_dirs(home);
251 if let Some(home) = home {
252 let home = PathBuf::from(home);
253 dirs.push(home.join(".bun/bin"));
254 dirs.push(home.join("Library/pnpm"));
255 dirs.push(home.join(".local/share/pnpm"));
256 dirs.push(home.join(".local/share/mise/shims"));
257 dirs.push(home.join(".deno/bin"));
258 dirs.push(home.join(".volta/bin"));
259 }
260 dirs
261}
262
263#[cfg(unix)]
264fn append_missing_standard_dirs<D>(
265 path: &OsStr,
266 home: Option<&OsStr>,
267 mut dir_exists: D,
268) -> OsString
269where
270 D: FnMut(&Path) -> bool,
271{
272 let mut entries: Vec<PathBuf> = std::env::split_paths(path).collect();
273 let mut seen: HashSet<PathBuf> = entries.iter().cloned().collect();
274
275 for dir in user_standard_path_dirs(home) {
276 if dir_exists(&dir) && seen.insert(dir.clone()) {
277 entries.push(dir);
278 }
279 }
280
281 std::env::join_paths(entries).unwrap_or_else(|_| path.to_os_string())
282}
283
284#[cfg(unix)]
285#[derive(Debug)]
286struct LoginPathProbe {
287 shell: PathBuf,
288 path: Option<OsString>,
289}
290
291#[cfg(unix)]
292fn probe_login_shell_path() -> LoginPathProbe {
293 let candidates = login_shell_candidates();
294 let fallback_shell = candidates
295 .first()
296 .cloned()
297 .unwrap_or_else(|| PathBuf::from("/bin/sh"));
298 let deadline = Instant::now() + LOGIN_SHELL_PATH_PROBE_TOTAL_BUDGET;
299
300 for shell in candidates {
301 let now = Instant::now();
302 if now >= deadline {
303 break;
304 }
305 let timeout = LOGIN_SHELL_PATH_PROBE_TIMEOUT.min(deadline.saturating_duration_since(now));
306 if let Some(path) = probe_login_shell_path_once(&shell, timeout) {
307 if login_path_is_acceptable(&path) {
308 return LoginPathProbe {
312 shell: fallback_shell,
313 path: Some(path),
314 };
315 }
316 }
317 }
318
319 LoginPathProbe {
320 shell: fallback_shell,
321 path: None,
322 }
323}
324
325#[cfg(unix)]
326fn login_shell_candidates() -> Vec<PathBuf> {
327 if let Some(value) = crate::environment::non_empty_os_var("AFT_TEST_LOGIN_SHELL_CANDIDATES") {
331 let candidates = std::env::split_paths(&value).collect::<Vec<_>>();
332 if !candidates.is_empty() {
333 return candidates;
334 }
335 }
336
337 let mut candidates = Vec::new();
338 if let Some(shell) = std::env::var_os("SHELL").filter(|value| !value.is_empty()) {
339 candidates.push(PathBuf::from(shell));
340 }
341 let zsh = PathBuf::from("/bin/zsh");
342 let bash = PathBuf::from("/bin/bash");
343 if !candidates.contains(&zsh) {
344 candidates.push(zsh);
345 }
346 if !candidates.contains(&bash) {
347 candidates.push(bash);
348 }
349 candidates
350}
351
352#[cfg(unix)]
353fn set_nonblocking<F: AsRawFd>(file: &F) -> std::io::Result<()> {
354 let fd = file.as_raw_fd();
355 unsafe {
356 let flags = libc::fcntl(fd, libc::F_GETFL);
357 if flags < 0 {
358 return Err(std::io::Error::last_os_error());
359 }
360 if libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) < 0 {
361 return Err(std::io::Error::last_os_error());
362 }
363 }
364 Ok(())
365}
366
367#[cfg(unix)]
368fn probe_login_shell_path_once(shell: &Path, timeout: Duration) -> Option<OsString> {
369 let mut command = Command::new(shell);
370
371 command
372 .arg(probe_shell_flags(shell))
373 .arg(probe_shell_command(shell))
374 .stdin(Stdio::null())
375 .stdout(Stdio::piped())
376 .stderr(Stdio::null());
377 unsafe {
380 command.pre_exec(|| {
381 if libc::setsid() == -1 {
382 return Err(std::io::Error::last_os_error());
383 }
384 Ok(())
385 });
386 }
387 let mut child = command.spawn().ok()?;
388 let mut stdout = child.stdout.take()?;
389 let _ = set_nonblocking(&stdout);
390 let mut output_bytes = Vec::new();
391 let mut buf = [0u8; 1024];
392
393 let deadline = Instant::now() + timeout;
394 loop {
395 use std::io::Read;
396 match stdout.read(&mut buf) {
397 Ok(0) => {
398 let wait_deadline = (Instant::now() + Duration::from_secs(1)).min(deadline);
402 loop {
403 match child.try_wait() {
404 Ok(Some(_)) => break,
405 Ok(None) if Instant::now() >= wait_deadline => {
406 kill_login_shell_probe(&mut child);
407 break;
408 }
409 Ok(None) => {
410 std::thread::sleep(Duration::from_millis(10));
411 }
412 Err(_) => {
413 kill_login_shell_probe(&mut child);
414 break;
415 }
416 }
417 }
418 return extract_probe_path(&output_bytes);
419 }
420 Ok(n) => {
421 output_bytes.extend_from_slice(&buf[..n]);
422 }
423 Err(ref error) if error.kind() == std::io::ErrorKind::WouldBlock => {}
424 Err(_) => {
425 kill_login_shell_probe(&mut child);
426 return None;
427 }
428 }
429
430 match child.try_wait() {
431 Ok(Some(_)) => {
432 loop {
433 match stdout.read(&mut buf) {
434 Ok(0) => break,
435 Ok(n) => output_bytes.extend_from_slice(&buf[..n]),
436 Err(ref error) if error.kind() == std::io::ErrorKind::WouldBlock => {
437 break;
438 }
439 Err(_) => break,
440 }
441 }
442 let _ = child.wait();
443 return extract_probe_path(&output_bytes);
444 }
445 Ok(None) if Instant::now() >= deadline => {
446 kill_login_shell_probe(&mut child);
447 return None;
448 }
449 Ok(None) => {
450 std::thread::sleep(Duration::from_millis(25));
451 }
452 Err(_) => {
453 kill_login_shell_probe(&mut child);
454 return None;
455 }
456 }
457 }
458}
459
460#[cfg(unix)]
461fn probe_shell_flags(_shell: &Path) -> &'static str {
462 "-lic"
465}
466
467#[cfg(unix)]
468fn probe_shell_command(shell: &Path) -> &'static str {
469 let name = shell.file_name().and_then(|name| name.to_str());
470 if name.is_some_and(|name| name.eq_ignore_ascii_case("fish")) {
471 r#"printf '\n__AFT_PATH_BEGIN__%s__AFT_PATH_END__\n' (string join : $PATH)"#
472 } else {
473 r#"printf '\n__AFT_PATH_BEGIN__%s__AFT_PATH_END__\n' "$PATH""#
474 }
475}
476
477#[cfg(unix)]
478fn extract_probe_path(output: &[u8]) -> Option<OsString> {
479 const BEGIN: &[u8] = b"__AFT_PATH_BEGIN__";
480 const END: &[u8] = b"__AFT_PATH_END__";
481 let begin = output
482 .windows(BEGIN.len())
483 .position(|window| window == BEGIN)?
484 + BEGIN.len();
485 let end = output[begin..]
486 .windows(END.len())
487 .position(|window| window == END)?
488 + begin;
489 Some(OsString::from_vec(output[begin..end].to_vec()))
490}
491
492#[cfg(unix)]
493fn kill_login_shell_probe(child: &mut std::process::Child) {
494 let pid = child.id() as i32;
495 if pid > 0 {
496 unsafe {
498 let _ = libc::kill(-pid, libc::SIGKILL);
499 }
500 }
501 let _ = child.kill();
502 let _ = child.wait();
503}
504
505#[cfg(unix)]
506fn login_path_is_acceptable(path: &OsStr) -> bool {
507 let bytes = path.as_bytes();
508 if bytes.is_empty()
509 || bytes
510 .iter()
511 .any(|byte| matches!(byte, b'\0' | b'\n' | b'\r'))
512 {
513 return false;
514 }
515
516 if bytes.contains(&b' ') {
517 let mut abs_count = 0;
518 for part in bytes.split(|&byte| byte == b' ') {
519 if part.first() == Some(&b'/') {
520 abs_count += 1;
521 }
522 }
523 if abs_count > 1 {
524 return false;
525 }
526 }
527
528 bytes
529 .split(|byte| *byte == b':')
530 .all(|entry| entry.first() == Some(&b'/'))
531}
532
533#[cfg(unix)]
534fn merge_current_and_login_path(current_path: &OsStr, login_path: &OsStr) -> OsString {
535 let mut seen = HashSet::new();
536 let mut merged = Vec::new();
537
538 for entry in std::env::split_paths(current_path).chain(std::env::split_paths(login_path)) {
541 if seen.insert(entry.clone()) {
542 merged.push(entry);
543 }
544 }
545
546 std::env::join_paths(merged).unwrap_or_else(|_| current_path.to_os_string())
547}
548
549#[cfg(unix)]
550#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
551struct EffectivePathInput {
552 file: String,
553 mtime_ns: Option<i128>,
554 size: Option<u64>,
555}
556
557#[cfg(unix)]
558#[derive(Clone, Debug, Deserialize, Serialize)]
559struct EffectivePathCache {
560 schema: u32,
561 shell: String,
562 path: Option<String>,
563 probed_at_unix: u64,
564 inputs: Vec<EffectivePathInput>,
565}
566
567#[cfg(unix)]
568fn read_effective_path_cache(path: &Path) -> Option<EffectivePathCache> {
569 let content = std::fs::read_to_string(path).ok()?;
570 serde_json::from_str(&content).ok()
571}
572
573#[cfg(unix)]
574fn write_effective_path_cache(path: &Path, cache: &EffectivePathCache) -> std::io::Result<()> {
575 if let Some(parent) = path.parent() {
576 std::fs::create_dir_all(parent)?;
577 }
578 let content = serde_json::to_vec(cache).map_err(std::io::Error::other)?;
579 let temporary = path.with_file_name(format!(
580 ".{}.{}.tmp",
581 path.file_name()
582 .and_then(|name| name.to_str())
583 .unwrap_or("effective-path.json"),
584 std::process::id()
585 ));
586 let result = (|| {
587 std::fs::write(&temporary, content)?;
588 std::fs::rename(&temporary, path)?;
589 Ok(())
590 })();
591 if result.is_err() {
592 let _ = std::fs::remove_file(&temporary);
593 }
594 result
595}
596
597#[cfg(unix)]
598fn cache_matches_current_shell(cache: &EffectivePathCache, candidates: &[PathBuf]) -> bool {
599 let Some(current_shell) = candidates.first() else {
600 return false;
601 };
602 cache.schema == EFFECTIVE_PATH_CACHE_SCHEMA
603 && cache.shell == current_shell.to_string_lossy()
604 && cache.inputs == shell_startup_inputs(current_shell)
605 && cache
606 .path
607 .as_deref()
608 .map(|path| login_path_is_acceptable(OsStr::new(path)))
609 .unwrap_or(true)
610}
611
612#[cfg(unix)]
613fn shell_startup_inputs(shell: &Path) -> Vec<EffectivePathInput> {
614 let name = shell.file_name().and_then(|name| name.to_str());
615 let is_zsh = name.is_some_and(|name| name.eq_ignore_ascii_case("zsh"));
616 let mut files = if is_zsh {
617 vec![
618 PathBuf::from("/etc/zshenv"),
619 PathBuf::from("/etc/zprofile"),
620 PathBuf::from("/etc/zshrc"),
621 ]
622 } else {
623 vec![PathBuf::from("/etc/profile")]
624 };
625 if let Some(home) = std::env::var_os("HOME").filter(|value| !value.is_empty()) {
626 let home = PathBuf::from(home);
627 let names: &[&str] = if is_zsh {
628 &[".zshenv", ".zprofile", ".zshrc"]
629 } else {
630 &[".bash_profile", ".bash_login", ".profile", ".bashrc"]
631 };
632 files.extend(names.iter().map(|name| home.join(name)));
633 }
634 files
635 .into_iter()
636 .map(|file| match std::fs::metadata(&file) {
637 Ok(metadata) => EffectivePathInput {
638 file: file.to_string_lossy().into_owned(),
639 mtime_ns: Some(
640 i128::from(metadata.mtime()) * 1_000_000_000
641 + i128::from(metadata.mtime_nsec()),
642 ),
643 size: Some(metadata.len()),
644 },
645 Err(_) => EffectivePathInput {
646 file: file.to_string_lossy().into_owned(),
647 mtime_ns: None,
648 size: None,
649 },
650 })
651 .collect()
652}
653
654#[cfg(unix)]
655fn unix_timestamp_secs() -> u64 {
656 SystemTime::now()
657 .duration_since(UNIX_EPOCH)
658 .unwrap_or_default()
659 .as_secs()
660}
661
662#[cfg(unix)]
664pub fn refresh_login_shell_path_cache(cache_path: &Path) -> Result<(), String> {
665 let result = probe_login_shell_path();
666 let cache = EffectivePathCache {
667 schema: EFFECTIVE_PATH_CACHE_SCHEMA,
668 shell: result.shell.to_string_lossy().into_owned(),
669 path: result
670 .path
671 .as_ref()
672 .map(|path| path.to_string_lossy().into_owned()),
673 probed_at_unix: unix_timestamp_secs(),
674 inputs: shell_startup_inputs(&result.shell),
675 };
676 write_effective_path_cache(cache_path, &cache)
677 .map_err(|error| format!("write {}: {error}", cache_path.display()))
678}
679
680#[cfg(not(unix))]
681pub fn refresh_login_shell_path_cache(_cache_path: &std::path::Path) -> Result<(), String> {
682 Ok(())
683}
684
685#[cfg(unix)]
686fn spawn_cached_path_refresh() {
687 if std::env::var_os("AFT_TEST_LOGIN_SHELL_CANDIDATES").is_some() {
691 return;
692 }
693
694 let cache_path = {
695 let mut guard = EFFECTIVE_PATH_STATE
696 .lock()
697 .unwrap_or_else(|error| error.into_inner());
698 let Some(state) = guard.as_mut() else {
699 return;
700 };
701 if state.source != ProbeSource::Cache || state.refresh_started {
702 return;
703 }
704 state.refresh_started = true;
705 state.cache_path.clone()
706 };
707
708 let Ok(executable) = std::env::current_exe() else {
709 return;
710 };
711 let mut command = Command::new(executable);
712 command
713 .arg("--probe-login-shell-path")
714 .arg(cache_path)
715 .stdin(Stdio::null())
716 .stdout(Stdio::null())
717 .stderr(Stdio::null());
718 unsafe {
722 command.pre_exec(|| {
723 if libc::setsid() == -1 {
724 return Err(std::io::Error::last_os_error());
725 }
726 Ok(())
727 });
728 }
729 let _ = command.spawn();
730}
731
732#[cfg(test)]
733#[cfg(unix)]
734mod tests {
735 use super::*;
736 use std::fs;
737 use std::os::unix::fs::PermissionsExt;
738
739 struct EnvVarGuard {
740 key: &'static str,
741 old_value: Option<OsString>,
742 }
743
744 impl EnvVarGuard {
745 fn set(key: &'static str, value: &str) -> Self {
746 let old_value = std::env::var_os(key);
747 std::env::set_var(key, value);
748 Self { key, old_value }
749 }
750 }
751
752 impl Drop for EnvVarGuard {
753 fn drop(&mut self) {
754 if let Some(val) = &self.old_value {
755 std::env::set_var(self.key, val);
756 } else {
757 std::env::remove_var(self.key);
758 }
759 }
760 }
761
762 fn write_executable_shim(path: &Path, body: &str) {
763 fs::write(path, body).unwrap();
764 let mut permissions = fs::metadata(path).unwrap().permissions();
765 permissions.set_mode(0o755);
766 fs::set_permissions(path, permissions).unwrap();
767 }
768
769 #[test]
770 fn probe_entries_are_appended_after_current_entries_without_duplicates() {
771 let current = OsStr::new("/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin");
772 let login = OsString::from("/usr/bin:/custom/bin:/opt/homebrew/bin");
773
774 let effective = merge_current_and_login_path(current, &login);
775
776 assert_eq!(
777 effective,
778 OsString::from("/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/custom/bin")
779 );
780 }
781
782 #[test]
783 fn probe_shell_flags_cover_login_and_interactive_startup_files() {
784 assert_eq!(probe_shell_flags(Path::new("/bin/zsh")), "-lic");
785 assert_eq!(probe_shell_flags(Path::new("/bin/bash")), "-lic");
786 assert_eq!(
787 probe_shell_flags(Path::new("/opt/homebrew/bin/fish")),
788 "-lic"
789 );
790 }
791
792 #[test]
793 fn marker_extraction_ignores_shell_startup_noise() {
794 let output = b"banner before\n__AFT_PATH_BEGIN__/custom/bin:/usr/bin__AFT_PATH_END__\nbanner after\n";
795
796 assert_eq!(
797 extract_probe_path(output),
798 Some(OsString::from("/custom/bin:/usr/bin"))
799 );
800 }
801
802 const TEST_PROBE_SUCCESS_TIMEOUT: Duration = Duration::from_secs(30);
806
807 #[test]
808 fn zsh_probe_uses_interactive_login_and_reads_zshrc() {
809 let _guard = crate::test_env::process_env_lock();
810 let dir = tempfile::tempdir().unwrap();
811 let home = dir.path().join("home");
812 let custom_bin = home.join(".custom/bin");
813 let shell = dir.path().join("zsh");
814 fs::create_dir_all(&custom_bin).unwrap();
815 fs::write(
816 home.join(".zshrc"),
817 format!(
818 "printf 'banner before\n'; export PATH=\"$PATH:{}\"; printf 'banner after\n'\n",
819 custom_bin.display()
820 ),
821 )
822 .unwrap();
823 write_executable_shim(
824 &shell,
825 r#"#!/bin/sh
826if [ "$1" != '-lic' ]; then
827 exit 64
828fi
829if [ -f "$ZDOTDIR/.zshrc" ]; then
830 . "$ZDOTDIR/.zshrc"
831fi
832eval "$2"
833"#,
834 );
835
836 let _path_guard = EnvVarGuard::set("PATH", "/usr/bin:/bin");
837 let _home_guard = EnvVarGuard::set("HOME", home.to_str().unwrap());
838 let _zdotdir_guard = EnvVarGuard::set("ZDOTDIR", home.to_str().unwrap());
839 let _ = Command::new(&shell).arg("--warmup").output();
844 let probed = probe_login_shell_path_once(&shell, TEST_PROBE_SUCCESS_TIMEOUT);
848
849 assert_eq!(
850 probed,
851 Some(OsString::from(format!(
852 "/usr/bin:/bin:{}",
853 custom_bin.display()
854 )))
855 );
856 }
857
858 #[test]
859 fn invalid_probe_paths_are_rejected() {
860 let rejected = vec![
861 OsString::new(),
862 OsString::from("/fake/login/bin\n/usr/bin"),
863 OsString::from("/fake/login/bin:relative/bin"),
864 OsString::from_vec(b"/fake/login/bin\0/usr/bin".to_vec()),
865 OsString::from("/usr/bin /bin /opt/homebrew/bin"), ];
867
868 for probe_path in rejected {
869 assert!(!login_path_is_acceptable(&probe_path));
870 }
871 }
872
873 #[test]
874 fn inline_probe_total_budget_caps_multiple_hanging_candidates() {
875 let _guard = crate::test_env::process_env_lock();
876 let dir = tempfile::tempdir().expect("create tempdir");
877 let first = dir.path().join("first-shell");
878 let second = dir.path().join("second-shell");
879 write_executable_shim(&first, "#!/bin/sh\nsleep 10\n");
880 write_executable_shim(&second, "#!/bin/sh\nsleep 10\n");
881 let candidates = std::env::join_paths([&first, &second]).unwrap();
882 let _candidates_guard = EnvVarGuard::set(
883 "AFT_TEST_LOGIN_SHELL_CANDIDATES",
884 candidates.to_str().unwrap(),
885 );
886
887 let started = Instant::now();
888 let result = probe_login_shell_path();
889
890 assert!(result.path.is_none());
891 assert!(
892 started.elapsed() < Duration::from_millis(4500),
893 "two hanging candidates exceeded the four-second total budget"
894 );
895 }
896
897 #[test]
898 fn login_shell_probe_times_out() {
899 let dir = tempfile::tempdir().expect("create tempdir");
900 let shell = dir.path().join("slow-login-shell");
901 fs::write(
902 &shell,
903 "#!/bin/sh\nsleep 10\nprintf '%s' '/fake/login/bin:/usr/bin:/bin'\n",
904 )
905 .expect("write fake shell");
906 let mut permissions = fs::metadata(&shell)
907 .expect("fake shell metadata")
908 .permissions();
909 permissions.set_mode(0o755);
910 fs::set_permissions(&shell, permissions).expect("chmod fake shell");
911
912 let started = Instant::now();
913 let probed = probe_login_shell_path_once(&shell, LOGIN_SHELL_PATH_PROBE_TIMEOUT);
914
915 assert!(probed.is_none());
916 assert!(
919 started.elapsed() < Duration::from_secs(8),
920 "login-shell PATH probe exceeded the 8s test budget"
921 );
922 }
923
924 #[test]
925 fn test_login_shell_candidates_includes_fallbacks() {
926 let _guard = crate::test_env::process_env_lock();
927 let _shell_guard = EnvVarGuard::set("SHELL", "/opt/zerobrew/bin/fish");
928 let candidates = login_shell_candidates();
929 assert_eq!(candidates[0], PathBuf::from("/opt/zerobrew/bin/fish"));
930 assert!(candidates.contains(&PathBuf::from("/bin/zsh")));
931 assert!(candidates.contains(&PathBuf::from("/bin/bash")));
932 }
933
934 #[test]
935 fn effective_path_cache_round_trips_and_rejects_changed_inputs() {
936 let _guard = crate::test_env::process_env_lock();
937 let temp = tempfile::tempdir().unwrap();
938 let home = temp.path().join("home");
939 fs::create_dir_all(&home).unwrap();
940 let _home_guard = EnvVarGuard::set("HOME", home.to_str().unwrap());
941 let shell = PathBuf::from("/bin/bash");
942 let cache_path = temp.path().join("effective-path.json");
943 let cache = EffectivePathCache {
944 schema: EFFECTIVE_PATH_CACHE_SCHEMA,
945 shell: shell.to_string_lossy().into_owned(),
946 path: Some("/custom/bin:/usr/bin:/bin".to_string()),
947 probed_at_unix: unix_timestamp_secs(),
948 inputs: shell_startup_inputs(&shell),
949 };
950
951 write_effective_path_cache(&cache_path, &cache).unwrap();
952 let restored = read_effective_path_cache(&cache_path).unwrap();
953 assert!(cache_matches_current_shell(&restored, &[shell.clone()]));
954
955 fs::write(home.join(".bashrc"), "export PATH=changed\n").unwrap();
956 assert!(!cache_matches_current_shell(&restored, &[shell]));
957 }
958
959 #[test]
960 fn probe_failure_falls_back_to_current_path_and_appends_standard_dirs() {
961 let home = Path::new("/home/alice");
962 let current = OsStr::new("/usr/bin:/bin");
963 let missing_shell = Path::new("/nonexistent/shell");
964
965 assert!(probe_login_shell_path_once(missing_shell, Duration::from_millis(10)).is_none());
966
967 let enriched = append_missing_standard_dirs(current, Some(home.as_os_str()), |dir| {
968 dir == Path::new("/home/alice/.bun/bin")
969 });
970 assert_eq!(
971 enriched,
972 OsString::from("/usr/bin:/bin:/home/alice/.bun/bin")
973 );
974 }
975
976 #[test]
977 fn test_append_missing_standard_dirs() {
978 let home = OsStr::new("/home/alice");
979 let path = OsStr::new("/usr/bin:/bin");
980
981 let dir_exists = |dir: &Path| dir == Path::new("/home/alice/.bun/bin");
983
984 let enriched = append_missing_standard_dirs(path, Some(home), dir_exists);
985 assert_eq!(
986 enriched,
987 OsString::from("/usr/bin:/bin:/home/alice/.bun/bin")
988 );
989 }
990
991 #[test]
992 fn test_append_missing_standard_dirs_dedup_and_order() {
993 let home = OsStr::new("/home/alice");
994 let path = OsStr::new("/home/alice/.bun/bin:/usr/bin:/bin");
996
997 let dir_exists = |dir: &Path| {
998 dir == Path::new("/home/alice/.bun/bin") || dir == Path::new("/opt/homebrew/bin")
999 };
1000
1001 let enriched = append_missing_standard_dirs(path, Some(home), dir_exists);
1002 assert_eq!(
1004 enriched,
1005 OsString::from("/home/alice/.bun/bin:/usr/bin:/bin:/opt/homebrew/bin")
1006 );
1007 }
1008}