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::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
47/// Compute and export AFT's process PATH.
48///
49/// Call this during process startup, before AFT starts worker threads or async
50/// executors. Mutating process environment variables while other threads may be
51/// reading them is not safe on Unix, so later code should read the cached value
52/// with [`effective_path`] instead of calling this initializer again.
53pub 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/// Create a new `Command` with the effective PATH set on Unix.
67#[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/// On Windows the process PATH is already correct (registry-backed
75/// environment block); pass through without touching the child env.
76#[cfg(not(unix))]
77pub fn new_command<S: AsRef<OsStr>>(program: S) -> std::process::Command {
78    std::process::Command::new(program)
79}
80
81/// Return the cached PATH that subprocesses should inherit.
82///
83/// On Windows this is the process PATH unchanged: Windows daemon environments
84/// already receive PATH from the registry-backed environment block.
85#[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    // Test seam: integration tests construct exact PATHs (e.g. to simulate a
95    // missing formatter binary); probing and enrichment would re-add real tool
96    // dirs from the host and break that isolation. Checked at runtime because
97    // the spawned test binary is a production build.
98    // "0" reads as unset so the PATH feature's own integration tests can
99    // opt back in to probing under a test harness that defaults the seam on.
100    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, &current);
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, &current);
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(&current, 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, &current)
182        } else if let Some(memo_path) = read_login_path_memo() {
183            merge_login_and_current_path(&memo_path, &current)
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/// Core tool dirs whose absence from PATH signals an impoverished daemon
226/// environment worth paying a login-shell probe for. Deliberately excludes
227/// the interactive-gated extras below: those are appended unconditionally by
228/// `append_missing_standard_dirs`, so missing them alone never justifies a
229/// probe.
230#[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/// All dirs merged into every constructed PATH when present on disk. Includes
245/// installers that only amend interactive shell rc blocks (bun, pnpm, mise,
246/// deno, volta), which even a successful login-shell probe cannot see.
247#[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    // Run the probe in its own session so the timeout can kill login-shell
357    // startup helpers as well as the shell process itself.
358    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                // No data available right now
401            }
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        // Negative PID targets the process group created by setsid above.
443        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"), // fish-shaped space-joined
591        ];
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        // Force impoverished PATH
668        let _path_guard = EnvVarGuard::set("PATH", "/usr/bin:/bin");
669
670        // Set SHELL to a non-existent shell so probe fails
671        let _shell_guard = EnvVarGuard::set("SHELL", "/nonexistent/shell");
672
673        // Set candidates override to nonexistent shell to force probe failure
674        let _candidates_guard =
675            EnvVarGuard::set("AFT_TEST_LOGIN_SHELL_CANDIDATES", "/nonexistent/shell");
676
677        // First call: probe fails, no memo, falls back to the impoverished
678        // PATH (plus whatever standard dirs exist on the test machine, which
679        // the unconditional append may add — assert the prefix, not equality).
680        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        // Second call immediately: should NOT retry probe (returns cached fallback)
693        let path2 = effective_path();
694        assert_eq!(path2, path1);
695
696        // Simulate 61 seconds passing by modifying last_probe_attempt
697        {
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        // Now write a memo so the next probe/fallback can succeed
706        write_login_path_memo(OsStr::new("/opt/homebrew/bin:/usr/bin:/bin"));
707
708        // Third call: should retry probe (which still fails because SHELL is nonexistent),
709        // but now it reads from the memo!
710        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        // Mock dir_exists to return true only for ~/.bun/bin
720        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        // ~/.bun/bin is already in the path, but /opt/homebrew/bin is missing
733        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        // /opt/homebrew/bin should be appended at the end, and ~/.bun/bin should not be duplicated
741        assert_eq!(
742            enriched,
743            OsString::from("/home/alice/.bun/bin:/usr/bin:/bin:/opt/homebrew/bin")
744        );
745    }
746}