use anyhow::{Context, Result, bail};
use std::collections::BTreeMap;
use std::os::unix::fs::{MetadataExt, OpenOptionsExt};
use std::os::unix::process::CommandExt;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
pub const CAPTURE_TIMEOUT: Duration = Duration::from_secs(10);
const MAX_ENVIRONMENT: u64 = 1 << 20;
const OUT_VAR: &str = "HENNERY_ENV_OUT";
fn system_dirs(macos: bool) -> &'static [&'static str] {
if macos {
&["/usr/bin", "/bin", "/usr/sbin", "/sbin"]
} else {
&["/usr/bin", "/bin"]
}
}
pub fn shell_environment(outer: &BTreeMap<String, String>, shell: &Path, macos: bool) -> Vec<(String, String)> {
let mut env: Vec<(String, String)> = [
"HOME",
"USER",
"LOGNAME",
"LANG",
"TMPDIR",
"XDG_CONFIG_HOME",
"XDG_DATA_HOME",
"XDG_STATE_HOME",
"XDG_CACHE_HOME",
"XDG_RUNTIME_DIR",
]
.into_iter()
.filter_map(|name| outer.get(name).map(|value| (name.to_string(), value.clone())))
.collect();
env.push(("SHELL".into(), shell.display().to_string()));
env.push(("TERM".into(), "dumb".into()));
env.push(("PATH".into(), system_dirs(macos).join(":")));
env
}
pub fn login_environment(
shell: &Path,
env: &[(String, String)],
timeout: Duration,
) -> Result<BTreeMap<String, String>> {
static CAPTURES: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let out = std::env::temp_dir().join(format!(
"hennery-env-{}-{}-{}",
std::process::id(),
CAPTURES.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
));
std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&out)
.with_context(|| format!("create {}", out.display()))?;
let result = run_shell(shell, env, &out, timeout).and_then(|()| {
use std::io::Read;
let mut bytes = Vec::new();
std::fs::File::open(&out)?
.take(MAX_ENVIRONMENT + 1)
.read_to_end(&mut bytes)?;
if bytes.len() as u64 > MAX_ENVIRONMENT {
bail!("the login shell's environment is over {MAX_ENVIRONMENT} bytes");
}
Ok(parse_env0(&bytes))
});
let _ = std::fs::remove_file(&out);
let captured = result?;
if captured.is_empty() {
bail!("{} -l -i printed no environment", shell.display());
}
Ok(captured)
}
fn run_shell(shell: &Path, env: &[(String, String)], out: &Path, timeout: Duration) -> Result<()> {
let mut cmd = std::process::Command::new(shell);
cmd.args(["-l", "-i", "-c", &format!("/usr/bin/env -0 > \"${OUT_VAR}\"")])
.env_clear()
.envs(env.iter().map(|(k, v)| (k, v)))
.env(OUT_VAR, out)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
unsafe {
cmd.pre_exec(|| {
if libc::setsid() < 0 {
return Err(std::io::Error::last_os_error());
}
Ok(())
});
}
let mut child = cmd
.spawn()
.with_context(|| format!("start the login shell {}", shell.display()))?;
let group = child.id() as libc::pid_t;
let deadline = Instant::now() + timeout;
let finished = loop {
match exited(group) {
Ok(true) => break true,
Ok(false) if Instant::now() >= deadline => break false,
Ok(false) => std::thread::sleep(Duration::from_millis(20)),
Err(err) => {
unsafe {
libc::kill(-group, libc::SIGKILL);
}
let _ = child.wait();
return Err(err).context("watch the login shell");
}
}
};
unsafe {
libc::kill(-group, libc::SIGKILL);
}
let status = child.wait()?;
if !finished {
bail!(
"{} -l -i did not finish within {} s",
shell.display(),
timeout.as_secs()
);
}
if !status.success() {
bail!("{} -l -i -c env failed ({status})", shell.display());
}
Ok(())
}
fn exited(pid: libc::pid_t) -> std::io::Result<bool> {
let mut info: libc::siginfo_t = unsafe { std::mem::zeroed() };
let flags = libc::WEXITED | libc::WNOWAIT | libc::WNOHANG;
if unsafe { libc::waitid(libc::P_PID, pid as libc::id_t, &mut info, flags) } < 0 {
return Err(std::io::Error::last_os_error());
}
#[cfg(target_os = "linux")]
let changed = unsafe { info.si_pid() };
#[cfg(not(target_os = "linux"))]
let changed = info.si_pid;
Ok(changed != 0)
}
pub fn parse_env0(bytes: &[u8]) -> BTreeMap<String, String> {
bytes
.split(|&b| b == 0)
.filter_map(|record| {
let record = String::from_utf8_lossy(record);
let (name, value) = record.split_once('=')?;
(!name.is_empty()).then(|| (name.to_string(), value.to_string()))
})
.collect()
}
#[derive(Debug, PartialEq, Eq)]
pub struct ServicePath {
pub path: String,
pub notes: Vec<String>,
}
pub fn service_path(captured: &BTreeMap<String, String>, home: &Path, macos: bool) -> Result<ServicePath> {
let Some(path) = captured.get("PATH") else {
bail!("the login shell's environment has no PATH");
};
let mut notes = Vec::new();
let mut entries: Vec<String> = Vec::new();
for entry in path.split(':') {
if entry.is_empty() {
continue;
}
if entry.chars().any(char::is_control) {
notes.push(format!("dropped {entry:?}: it holds a control character"));
continue;
}
if entry.contains('\u{fffd}') {
notes.push(format!("dropped {entry:?}: it is not valid UTF-8"));
continue;
}
if !entry.starts_with('/') {
notes.push(format!("dropped {entry}: not an absolute path"));
continue;
}
let entry = match stable(entry, captured, home) {
Stable::Keep => entry.to_string(),
Stable::Replace(with) => {
notes.push(format!("replaced {entry} with {with}"));
with
}
Stable::Drop(why) => {
notes.push(format!("dropped {entry}: {why}"));
continue;
}
};
if !entries.contains(&entry) {
entries.push(entry);
}
}
for dir in system_dirs(macos) {
if !entries.iter().any(|e| e == dir) {
entries.push((*dir).to_string());
}
}
let uid = unsafe { libc::getuid() };
for entry in &entries {
if entry.starts_with("/nix/store/") {
notes.push(format!(
"{entry} is in the Nix store: a garbage collection may remove it; prefer a profile's bin directory"
));
}
if let Ok(meta) = std::fs::metadata(entry)
&& (meta.mode() & 0o022 != 0 || (meta.uid() != uid && meta.uid() != 0))
{
notes.push(format!(
"{entry} can be written by another user, who could put a program there that agents would run"
));
}
}
Ok(ServicePath {
path: entries.join(":"),
notes,
})
}
enum Stable {
Keep,
Replace(String),
Drop(&'static str),
}
fn stable(entry: &str, captured: &BTreeMap<String, String>, home: &Path) -> Stable {
if entry.contains("/fnm_multishells/") {
let mut candidates: Vec<PathBuf> = Vec::new();
if let Some(dir) = captured.get("FNM_DIR") {
candidates.push(dir.into());
}
if let Some(data) = captured.get("XDG_DATA_HOME") {
candidates.push(Path::new(data).join("fnm"));
}
candidates.extend([
home.join(".local/share/fnm"),
home.join("Library/Application Support/fnm"),
home.join(".fnm"),
]);
return match candidates
.iter()
.map(|dir| dir.join("aliases/default/bin"))
.find(|bin| bin.is_dir())
{
Some(bin) => Stable::Replace(bin.display().to_string()),
None => Stable::Drop("fnm's per-shell directory, and fnm has no default alias"),
};
}
let nvm = captured
.get("NVM_DIR")
.map(PathBuf::from)
.unwrap_or_else(|| home.join(".nvm"));
let versions = nvm.join("versions/node");
if let Ok(rest) = Path::new(entry).strip_prefix(&versions)
&& rest.components().count() == 2
&& rest.ends_with("bin")
&& let Some(default) = nvm_default(&nvm)
{
let bin = versions.join(default).join("bin");
if bin != Path::new(entry) {
return Stable::Replace(bin.display().to_string());
}
}
Stable::Keep
}
fn nvm_default(nvm: &Path) -> Option<String> {
let alias = std::fs::read_to_string(nvm.join("alias/default")).ok()?;
let alias = alias.trim();
let installed: Vec<(Vec<u64>, String)> = std::fs::read_dir(nvm.join("versions/node"))
.ok()?
.filter_map(|e| {
let name = e.ok()?.file_name().into_string().ok()?;
let parts = name
.strip_prefix('v')?
.split('.')
.map(|p| p.parse().ok())
.collect::<Option<Vec<u64>>>()?;
Some((parts, name))
})
.collect();
let wanted: Option<Vec<u64>> = match alias {
"node" | "stable" => Some(Vec::new()),
_ => alias
.strip_prefix('v')
.unwrap_or(alias)
.split('.')
.map(|p| p.parse().ok())
.collect(),
};
let wanted = wanted?;
installed
.into_iter()
.filter(|(parts, _)| parts.starts_with(&wanted))
.max()
.map(|(_, name)| name)
}
#[cfg(test)]
pub(crate) fn marker_shell(dir: &Path) -> PathBuf {
use std::os::unix::fs::PermissionsExt;
let bin = dir.join("marker-bin");
std::fs::create_dir_all(&bin).unwrap();
let ran = dir.join("marker-ran");
let shell = dir.join("marker-shell");
std::fs::write(
&shell,
format!(
"#!/bin/sh\n[ \"$3\" = warm-up ] && exit 0\necho ran >> '{}'\nPATH='{}':$PATH; export PATH; shift 2; exec /bin/sh \"$@\"\n",
ran.display(),
bin.display()
),
)
.unwrap();
std::fs::set_permissions(&shell, std::fs::Permissions::from_mode(0o755)).unwrap();
let warm = Instant::now() + Duration::from_secs(60);
while let Err(err) = std::process::Command::new(&shell)
.args(["-l", "-i", "warm-up"])
.status()
{
assert!(
err.raw_os_error() == Some(libc::ETXTBSY) && Instant::now() < warm,
"{err}"
);
std::thread::sleep(Duration::from_millis(20));
}
shell
}
#[cfg(test)]
mod tests {
use super::*;
fn captured(path: &str) -> BTreeMap<String, String> {
BTreeMap::from([("PATH".to_string(), path.to_string())])
}
#[test]
fn env0_output_keeps_newlines_inside_values() {
let parsed = parse_env0(b"A=1\0B=two\nPATH=/evil\0PATH=/usr/bin\0junk\0");
assert_eq!(parsed["A"], "1");
assert_eq!(parsed["B"], "two\nPATH=/evil");
assert_eq!(parsed["PATH"], "/usr/bin");
assert!(!parsed.contains_key("junk"));
}
#[test]
fn relative_and_repeated_entries_go_and_the_system_dirs_are_appended() {
let home = tempfile::tempdir().unwrap();
let path = "/opt/a/bin::.:bin:/opt/a/bin:/bin:./x";
let linux = service_path(&captured(path), home.path(), false).unwrap();
assert_eq!(linux.path, "/opt/a/bin:/bin:/usr/bin");
assert_eq!(
linux
.notes
.iter()
.filter(|n| n.contains("not an absolute path"))
.count(),
3
);
let macos = service_path(&captured(path), home.path(), true).unwrap();
assert_eq!(macos.path, "/opt/a/bin:/bin:/usr/bin:/usr/sbin:/sbin");
assert!(service_path(&BTreeMap::new(), home.path(), false).is_err());
let control = service_path(&captured("/opt/a\nb/bin:/opt/c"), home.path(), false).unwrap();
assert_eq!(control.path, "/opt/c:/usr/bin:/bin");
let lossy = parse_env0(b"PATH=/opt/\xff/bin:/opt/c\0");
let lossy = service_path(&lossy, home.path(), false).unwrap();
assert_eq!(lossy.path, "/opt/c:/usr/bin:/bin");
assert!(
lossy.notes.iter().any(|n| n.contains("not valid UTF-8")),
"{:?}",
lossy.notes
);
}
#[test]
fn fnms_per_shell_directory_becomes_its_default_alias() {
let home = tempfile::tempdir().unwrap();
let fnm = home.path().join("fnm-data");
std::fs::create_dir_all(fnm.join("aliases/default/bin")).unwrap();
let entry = "/run/user/1000/fnm_multishells/1234_5678/bin";
let mut env = captured(&format!("{entry}:/usr/bin"));
env.insert("FNM_DIR".into(), fnm.display().to_string());
let path = service_path(&env, home.path(), false).unwrap();
let alias = fnm.join("aliases/default/bin").display().to_string();
assert_eq!(path.path, format!("{alias}:/usr/bin:/bin"));
assert!(path.notes.iter().any(|n| n.starts_with("replaced")), "{:?}", path.notes);
let elsewhere = tempfile::tempdir().unwrap();
std::fs::create_dir_all(elsewhere.path().join(".local/share/fnm/aliases/default/bin")).unwrap();
let path = service_path(&captured(entry), elsewhere.path(), false).unwrap();
assert!(path.path.starts_with(&format!(
"{}/.local/share/fnm/aliases/default/bin:",
elsewhere.path().display()
)));
let none = tempfile::tempdir().unwrap();
let path = service_path(&captured(entry), none.path(), false).unwrap();
assert_eq!(path.path, "/usr/bin:/bin");
assert!(
path.notes.iter().any(|n| n.contains("no default alias")),
"{:?}",
path.notes
);
}
#[test]
fn nvms_current_version_becomes_its_default() {
let home = tempfile::tempdir().unwrap();
let nvm = home.path().join(".nvm");
for v in ["v20.11.1", "v22.1.0", "v22.9.0", "v24.0.0"] {
std::fs::create_dir_all(nvm.join("versions/node").join(v).join("bin")).unwrap();
}
let current = nvm.join("versions/node/v24.0.0/bin").display().to_string();
let expect = |alias: &str, version: &str| {
std::fs::write(nvm.join("alias/default"), format!("{alias}\n")).unwrap();
let path = service_path(&captured(¤t), home.path(), false).unwrap();
let bin = nvm.join("versions/node").join(version).join("bin");
assert_eq!(path.path, format!("{}:/usr/bin:/bin", bin.display()), "alias {alias}");
};
std::fs::create_dir_all(nvm.join("alias")).unwrap();
expect("22", "v22.9.0");
expect("v20.11.1", "v20.11.1");
expect("22.1", "v22.1.0");
expect("node", "v24.0.0");
expect("lts/*", "v24.0.0");
expect("18", "v24.0.0");
}
#[test]
fn writable_and_nix_store_entries_are_named() {
use std::os::unix::fs::PermissionsExt;
let home = tempfile::tempdir().unwrap();
let open = home.path().join("open");
std::fs::create_dir(&open).unwrap();
std::fs::set_permissions(&open, std::fs::Permissions::from_mode(0o777)).unwrap();
let path = format!("{}:/nix/store/abc-git/bin", open.display());
let path = service_path(&captured(&path), home.path(), false).unwrap();
assert!(
path.notes.iter().any(|n| n.contains("another user")),
"{:?}",
path.notes
);
assert!(path.notes.iter().any(|n| n.contains("Nix store")), "{:?}", path.notes);
}
fn shell(name: &str) -> Option<PathBuf> {
let found = std::env::var_os("PATH").and_then(|path| {
std::env::split_paths(&path)
.map(|dir| dir.join(name))
.find(|candidate| candidate.is_file())
});
let required = std::env::var("HENNERY_REQUIRE_SHELLS").unwrap_or_default();
if found.is_none() {
assert!(
!required.split(',').any(|r| r.trim() == name),
"{name} is required (HENNERY_REQUIRE_SHELLS) but not on PATH"
);
eprintln!("skipped: {name} is not on PATH");
}
found
}
const LOADED_CAPTURE: Duration = Duration::from_secs(120);
fn capture(name: &str, files: &[(&str, &str)]) -> Option<(tempfile::TempDir, ServicePath)> {
let shell = shell(name)?;
let home = tempfile::tempdir().unwrap();
for (file, text) in files {
let path = home.path().join(file);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, text).unwrap();
}
let mut outer = BTreeMap::new();
outer.insert("HOME".to_string(), home.path().display().to_string());
outer.insert("USER".to_string(), "hennery-test".to_string());
outer.insert("CLAUDECODE".to_string(), "1".to_string());
let env = shell_environment(&outer, &shell, cfg!(target_os = "macos"));
assert!(env.iter().all(|(k, _)| k != "CLAUDECODE"));
let captured = login_environment(&shell, &env, LOADED_CAPTURE).unwrap();
assert!(!captured.contains_key("CLAUDECODE"), "{captured:?}");
let path = service_path(&captured, home.path(), cfg!(target_os = "macos")).unwrap();
Some((home, path))
}
fn assert_marker(path: &ServicePath, marker: &str) {
assert!(path.path.starts_with(&format!("{marker}:")), "{}", path.path);
let ours = std::env::var("PATH").unwrap_or_default();
for entry in ours.split(':').filter(|e| e.contains("/nix/store/")) {
assert!(
!path.path.split(':').any(|e| e == entry),
"{entry} leaked into {}",
path.path
);
}
}
#[test]
fn bash_login_path_is_captured() {
let profile = "echo welcome to bash\nexport PATH=\"/opt/hennery-marker-bash/bin:$PATH\"\nsleep 30 &\n";
if let Some((_home, path)) = capture("bash", &[(".bash_profile", profile)]) {
assert_marker(&path, "/opt/hennery-marker-bash/bin");
}
}
#[test]
fn zsh_login_path_is_captured() {
let rc = "print hello from zsh\npath=(/opt/hennery-marker-zsh/bin $path)\n";
if let Some((_home, path)) = capture("zsh", &[(".zshrc", rc)]) {
assert_marker(&path, "/opt/hennery-marker-zsh/bin");
}
}
#[test]
fn fish_login_path_is_captured() {
let config = "echo hello from fish\nset -gx PATH /opt/hennery-marker-fish/bin $PATH\n";
if let Some((_home, path)) = capture("fish", &[(".config/fish/config.fish", config)]) {
assert_marker(&path, "/opt/hennery-marker-fish/bin");
}
}
#[test]
fn a_shell_that_hangs_is_killed_after_the_timeout() {
let Some(bash) = shell("bash") else {
return;
};
let home = tempfile::tempdir().unwrap();
let pidfile = home.path().join("sleep.pid");
std::fs::write(
home.path().join(".bash_profile"),
format!("sleep 60 &\necho $! > {}\nwait\n", pidfile.display()),
)
.unwrap();
let outer = BTreeMap::from([("HOME".to_string(), home.path().display().to_string())]);
let env = shell_environment(&outer, &bash, false);
let mut timeout = Duration::from_secs(2);
let pid = loop {
let _ = std::fs::remove_file(&pidfile);
let started = Instant::now();
let err = login_environment(&bash, &env, timeout).unwrap_err();
let within = format!("did not finish within {} s", timeout.as_secs());
assert!(err.to_string().contains(&within), "{err}");
assert!(started.elapsed() < timeout + Duration::from_secs(8));
let written = std::fs::read_to_string(&pidfile).unwrap_or_default();
if let Ok(pid) = written.trim().parse::<i32>() {
break pid;
}
assert!(
timeout < Duration::from_secs(16),
"bash never wrote its sleep's pid within {} s",
timeout.as_secs()
);
timeout *= 2;
};
let deadline = Instant::now() + Duration::from_secs(5);
while unsafe { libc::kill(pid, 0) } == 0 {
assert!(Instant::now() < deadline, "the shell's sleep {pid} outlived it");
std::thread::sleep(Duration::from_millis(50));
}
}
}