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, 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
65/// Compute and export AFT's process PATH.
66///
67/// Call this during process startup, before AFT starts worker threads or async
68/// executors. Mutating process environment variables while other threads may be
69/// reading them is not safe on Unix, so later code should read the cached value
70/// with [`effective_path`] instead of calling this initializer again.
71pub 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
85/// Emit the single startup record after logging has been initialized.
86///
87/// PATH discovery runs before the logger and before threads, so this is split
88/// from [`initialize_process_path`] rather than delaying the environment write.
89pub 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/// Create a new `Command` with the effective PATH set on Unix.
116#[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/// On Windows the process PATH is already correct (registry-backed
124/// environment block); pass through without touching the child env.
125#[cfg(not(unix))]
126pub fn new_command<S: AsRef<OsStr>>(program: S) -> std::process::Command {
127    std::process::Command::new(program)
128}
129
130/// Return the cached PATH that subprocesses should inherit.
131///
132/// On Windows this is the process PATH unchanged: Windows daemon environments
133/// already receive PATH from the registry-backed environment block.
134#[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    // Test seam: integration tests construct exact PATHs (e.g. to simulate a
144    // missing formatter binary); probing and enrichment would re-add real tool
145    // dirs from the host and break that isolation. Checked at runtime because
146    // the spawned test binary is a production build.
147    // "0" reads as unset so the PATH feature's own integration tests can
148    // opt back in to probing under a test harness that defaults the seam on.
149    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    // Use the normal process-state resolver. It is available before the app is
168    // constructed, so no early XDG-only fallback can split this cache from AFT's
169    // configured storage root.
170    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(&current, 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/// Core tool directories are kept first in the standard-directory fallback.
232#[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/// All dirs merged into every constructed PATH when present on disk. Includes
247/// common installer locations that may not be represented in a shell probe.
248#[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                // Cache against the requested shell, not a fallback that happened
309                // to answer this probe. Otherwise a hanging $SHELL would pay its
310                // timeout again on every launch before the fallback can be reused.
311                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    // This runtime seam lets integration tests exercise the production binary
328    // with a deterministic shell. Normal launches still use SHELL and fall back
329    // to the common interactive shells below.
330    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    // Run the probe in its own session so the timeout can kill login-shell
378    // startup helpers as well as the shell process itself.
379    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                // EOF can arrive before a shell-startup child exits. Keep the
399                // existing one-second reap grace, but never let it overrun the
400                // caller's per-candidate share of the total startup budget.
401                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    // Login and interactive modes together cover the startup files that may
463    // contribute PATH entries, including bash's .bashrc and zsh's .zshrc.
464    "-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        // Negative PID targets the process group created by setsid above.
497        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    // Keep daemon-provided ordering and precedence; shell startup files only
539    // contribute entries that are not already present, appended at the end.
540    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/// Refresh a cache from the helper process without mutating its environment.
663#[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    // Integration tests point this seam at a deliberately sleeping shell and
688    // assert the serving binary does not run it on a cache hit. Production
689    // launches do not set the seam and always refresh through the helper.
690    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    // Run the cache-refresh helper independently so it can finish after the
719    // serving process exits. Its shell probe still enforces the per-candidate
720    // timeout and kills the probe process group when necessary.
721    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    /// Generous budget for tests that assert a PROBE SUCCEEDS (spawning a
803    /// real shim): decoupled from the production timeout so machine load
804    /// cannot convert a working probe into a test failure.
805    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        // Warm the freshly written shim with one untimed exec: macOS assesses
840        // never-seen executables on first exec (syspolicyd), which can take
841        // tens of seconds on a loaded machine and would read as a probe
842        // failure. The timed probe below then measures the shell, not the OS.
843        let _ = Command::new(&shell).arg("--warmup").output();
844        // Success-asserting probe tests use a generous budget: the production
845        // 3s timeout is a startup-cost bound, and a loaded machine can push a
846        // real shim spawn past it, which reads as a false test failure.
847        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"), // fish-shaped space-joined
866        ];
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        // Upper bound proves the 3s timeout fired instead of waiting out the
917        // 10s sleep; 8s leaves headroom for a loaded machine to reap the kill.
918        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        // Mock dir_exists to return true only for ~/.bun/bin
982        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        // ~/.bun/bin is already in the path, but /opt/homebrew/bin is missing
995        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        // /opt/homebrew/bin should be appended at the end, and ~/.bun/bin should not be duplicated
1003        assert_eq!(
1004            enriched,
1005            OsString::from("/home/alice/.bun/bin:/usr/bin:/bin:/opt/homebrew/bin")
1006        );
1007    }
1008}