diff --git a/experimental/silverwood/Cargo.lock b/experimental/silverwood/Cargo.lock index 8f571e6..2548829 100644 --- a/experimental/silverwood/Cargo.lock +++ b/experimental/silverwood/Cargo.lock @@ -1541,6 +1541,7 @@ name = "silverwood-cli" version = "0.1.0" dependencies = [ "clap", + "libc", "serde", "serde_json", "silverwood-core", diff --git a/experimental/silverwood/crates/silverwood-cli/Cargo.toml b/experimental/silverwood/crates/silverwood-cli/Cargo.toml index ad92620..87f3a9e 100644 --- a/experimental/silverwood/crates/silverwood-cli/Cargo.toml +++ b/experimental/silverwood/crates/silverwood-cli/Cargo.toml @@ -15,6 +15,7 @@ silverwood-core.workspace = true clap.workspace = true serde.workspace = true serde_json.workspace = true +libc.workspace = true [dev-dependencies] tempfile.workspace = true diff --git a/experimental/silverwood/crates/silverwood-cli/src/main.rs b/experimental/silverwood/crates/silverwood-cli/src/main.rs index 22148f9..d37e848 100644 --- a/experimental/silverwood/crates/silverwood-cli/src/main.rs +++ b/experimental/silverwood/crates/silverwood-cli/src/main.rs @@ -654,16 +654,69 @@ fn run_spawn( Err(format!("exec {}: {err}", program.display()).into()) } -/// Gather the spawn's dynamic inputs from the environment — frontend policy, like -/// [`resolve_forest_dir`] (`silverwood-core` never reads env). `HOME` is required -/// to reconstruct a login environment; `SHELL` defaults if unset. +/// The user's real login identity, read from the passwd database. +struct LoginIdentity { + name: String, + home: String, + shell: String, +} + +/// Read the user's real login identity from the passwd database (`getpwuid(getuid())`) +/// — authoritative and independent of the process env, which a nix devshell pollutes +/// (it overwrites `$SHELL`). On macOS this consults Directory Services; on Linux, +/// `/etc/passwd`/nsswitch. Returns `None` if the entry is missing or a field is +/// empty/non-UTF8, so the caller falls back to env vars. +fn login_identity() -> Option { + // SAFETY: getpwuid returns a pointer to a static buffer valid until the next getpw* + // call; we copy every field out immediately and never retain the pointer. spawn_seed + // is called once at startup on the main thread, so the shared buffer is not raced. + unsafe { + let pw = libc::getpwuid(libc::getuid()); + if pw.is_null() { + return None; + } + let pw = &*pw; + let field = |p: *const libc::c_char| -> Option { + if p.is_null() { + return None; + } + std::ffi::CStr::from_ptr(p) + .to_str() + .ok() + .filter(|s| !s.is_empty()) + .map(String::from) + }; + Some(LoginIdentity { + name: field(pw.pw_name)?, + home: field(pw.pw_dir)?, + shell: field(pw.pw_shell)?, + }) + } +} + +/// Gather the spawn's dynamic inputs — frontend policy, like [`resolve_forest_dir`] +/// (`silverwood-core` never touches env/the passwd DB). Identity (`home`/`user`/`shell`) +/// comes from the passwd database via [`login_identity`] so it is independent of the +/// process env (a nix devshell overwrites `$SHELL`); env vars are only a fallback. `HOME` +/// is required to reconstruct a login environment. `term`/`ssh_auth_sock` are session +/// context, so they stay env-sourced. fn spawn_seed() -> Result> { - let home = - std::env::var("HOME").map_err(|_| "no HOME: cannot reconstruct a login environment")?; - let user = std::env::var("USER") - .ok() + let login = login_identity(); + let home = login + .as_ref() + .map(|l| l.home.clone()) + .or_else(|| std::env::var("HOME").ok()) + .ok_or("no HOME: cannot reconstruct a login environment")?; + let user = login + .as_ref() + .map(|l| l.name.clone()) + .or_else(|| std::env::var("USER").ok()) .or_else(|| std::env::var("LOGNAME").ok()); - let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string()); + let shell = login + .as_ref() + .map(|l| l.shell.clone()) + .or_else(|| std::env::var("SHELL").ok()) + .unwrap_or_else(|| "/bin/zsh".to_string()); let term = std::env::var("TERM").ok(); let ssh_auth_sock = std::env::var("SSH_AUTH_SOCK").ok(); Ok(SpawnSeed { diff --git a/experimental/silverwood/crates/silverwood-core/src/spawn.rs b/experimental/silverwood/crates/silverwood-core/src/spawn.rs index bfc80ec..347d6e8 100644 --- a/experimental/silverwood/crates/silverwood-core/src/spawn.rs +++ b/experimental/silverwood/crates/silverwood-core/src/spawn.rs @@ -13,10 +13,12 @@ //! //! The env scrub mirrors what papyrus used to do inline: `process.env` in a //! frontend launched from a nix devshell is polluted (`IN_NIX_SHELL`, `DEVENV_*`, -//! an augmented `PATH`), so a spawned agent must not inherit it. Instead the -//! user's real login environment is reconstructed by running the login shell from -//! an empty base in a dir with no `.envrc`, and the two dynamic vars a terminal -//! needs (`TERM`, the ssh-agent socket) are overlaid on top. +//! an augmented `PATH`, an overwritten `$SHELL`), so a spawned agent must not inherit +//! it. Instead the user's real login environment is reconstructed by running the login +//! shell from an empty base in a dir with no `.envrc`, and the vars a login capture +//! can't produce are overlaid on top (`TERM`, the ssh-agent socket, `SHELL`, a default +//! `LANG`) — see `overlay_session_env`. The login shell itself comes from the passwd +//! database (via the frontend's `SpawnSeed`), not the polluted `$SHELL`. use std::collections::BTreeMap; use std::path::{Path, PathBuf}; @@ -25,17 +27,18 @@ use std::process::Command; use crate::workstream::CheckoutMode; /// The dynamic, machine/session inputs a spawn needs, gathered by the frontend. -/// Reading these from the environment is frontend policy (like the forest dir), -/// so core takes them explicitly rather than touching `std::env`. `home`/`user`/ -/// `shell` seed the login-env capture; `term`/`ssh_auth_sock` are overlaid onto -/// the captured env (a login capture can't have them). +/// Resolving these (identity from the passwd database, session context from the +/// environment) is frontend policy, so core takes them explicitly rather than touching +/// `std::env`/the passwd DB itself. `home`/`user`/`shell` seed the login-env capture; +/// `term`/`ssh_auth_sock` are overlaid onto the captured env (a login capture can't +/// have them). #[derive(Debug, Clone)] pub struct SpawnSeed { /// The user's home directory (`$HOME`). pub home: String, /// The login user (`$USER`/`$LOGNAME`), if known. pub user: Option, - /// Absolute path to the user's login shell (`$SHELL`). + /// Absolute path to the user's login shell (from the passwd database). pub shell: String, /// The terminal type to advertise (`$TERM`), if any. pub term: Option, @@ -126,16 +129,30 @@ fn agent_argv(mode: &CheckoutMode, cwd: &str, session_id: &str, resume: bool) -> } /// The scrubbed environment for a spawned shell: the captured login env (or a -/// reconstructed fallback), with `TERM` and the ssh-agent socket overlaid. +/// reconstructed fallback), with the session/identity vars a login capture can't +/// produce overlaid on top (see [`overlay_session_env`]). fn clean_env(seed: &SpawnSeed) -> BTreeMap { let mut env = capture_login_env(seed).unwrap_or_else(|| fallback_env(seed)); + overlay_session_env(&mut env, seed); + env +} + +/// Overlay the vars a login-shell capture can't produce onto `env`: the dynamic terminal +/// context (`TERM`, the ssh-agent socket), `SHELL` (set by `login(1)`, never exported by +/// the shell itself), and a default `LANG` when the capture has none (macOS injects the +/// locale via Terminal.app, not the login shell). `SHELL` is authoritative (a hard +/// overlay); `LANG` is only a default, so a shell that sets its own locale wins. +fn overlay_session_env(env: &mut BTreeMap, seed: &SpawnSeed) { if let Some(term) = &seed.term { env.insert("TERM".to_string(), term.clone()); } if let Some(sock) = &seed.ssh_auth_sock { env.insert("SSH_AUTH_SOCK".to_string(), sock.clone()); } - env + env.insert("SHELL".to_string(), seed.shell.clone()); + if !env.contains_key("LANG") && !env.contains_key("LC_ALL") { + env.insert("LANG".to_string(), "en_US.UTF-8".to_string()); + } } /// Run the user's login shell from an empty base and read back its exported env: @@ -328,4 +345,57 @@ mod tests { }; assert_eq!(plan.resolve_program(), Some(PathBuf::from("/bin/zsh"))); } + + fn seed_with_shell(shell: &str) -> SpawnSeed { + SpawnSeed { + home: "/home/x".to_string(), + user: Some("x".to_string()), + shell: shell.to_string(), + term: Some("xterm-256color".to_string()), + ssh_auth_sock: Some("/tmp/agent.sock".to_string()), + } + } + + #[test] + fn overlay_sets_shell_authoritatively() { + // A stale SHELL in the captured env must lose to the resolved login shell. + let mut env = BTreeMap::new(); + env.insert("SHELL".to_string(), "/bin/bash".to_string()); + overlay_session_env(&mut env, &seed_with_shell("/usr/bin/fish")); + assert_eq!(env.get("SHELL"), Some(&"/usr/bin/fish".to_string())); + } + + #[test] + fn overlay_applies_term_and_ssh_auth_sock() { + let mut env = BTreeMap::new(); + overlay_session_env(&mut env, &seed_with_shell("/usr/bin/fish")); + assert_eq!(env.get("TERM"), Some(&"xterm-256color".to_string())); + assert_eq!( + env.get("SSH_AUTH_SOCK"), + Some(&"/tmp/agent.sock".to_string()) + ); + } + + #[test] + fn overlay_defaults_lang_when_absent() { + let mut env = BTreeMap::new(); + overlay_session_env(&mut env, &seed_with_shell("/usr/bin/fish")); + assert_eq!(env.get("LANG"), Some(&"en_US.UTF-8".to_string())); + } + + #[test] + fn overlay_keeps_an_existing_lang() { + let mut env = BTreeMap::new(); + env.insert("LANG".to_string(), "de_DE.UTF-8".to_string()); + overlay_session_env(&mut env, &seed_with_shell("/usr/bin/fish")); + assert_eq!(env.get("LANG"), Some(&"de_DE.UTF-8".to_string())); + } + + #[test] + fn overlay_skips_lang_default_when_lc_all_is_set() { + let mut env = BTreeMap::new(); + env.insert("LC_ALL".to_string(), "C".to_string()); + overlay_session_env(&mut env, &seed_with_shell("/usr/bin/fish")); + assert!(!env.contains_key("LANG")); + } }