diff --git a/crates/ide-core/src/process.rs b/crates/ide-core/src/process.rs index 78182623..3ccb3853 100644 --- a/crates/ide-core/src/process.rs +++ b/crates/ide-core/src/process.rs @@ -1,6 +1,8 @@ //! Child-process helpers shared by every crate that spawns tools. +use std::ffi::{OsStr, OsString}; use std::process::Command; +use std::sync::OnceLock; /// Windows `CREATE_NO_WINDOW` creation flag, for call sites that combine it /// with other flags. Prefer [`no_window`] otherwise. @@ -18,3 +20,34 @@ pub fn no_window(cmd: &mut Command) -> &mut Command { } cmd } + +static SHELL_ENV: OnceLock> = OnceLock::new(); + +/// Records the login shell's environment, read once at startup off the main thread. +pub fn set_shell_env(vars: Vec<(OsString, OsString)>) { + let _ = SHELL_ENV.set(vars); +} + +/// The login shell's variables this process lacks: an app started from Finder/Dock gets only +/// PATH from the shell, not what rc files export (API keys and provider switches, SDK homes...). +/// Apply before a call site's own variables so those win. Empty until the shell has been read. +pub fn shell_env() -> impl Iterator { + SHELL_ENV + .get() + .into_iter() + .flatten() + .filter(|(k, _)| std::env::var_os(k).is_none()) + .map(|(k, v)| (k.as_os_str(), v.as_os_str())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn shell_env_adds_only_what_the_process_lacks() { + set_shell_env(vec![("OPENIDEA_SHELL_ONLY_VAR".into(), "1".into()), ("PATH".into(), "/from/shell".into())]); + let vars: Vec<_> = shell_env().collect(); + assert_eq!(vars, [(OsStr::new("OPENIDEA_SHELL_ONLY_VAR"), OsStr::new("1"))]); + } +} diff --git a/crates/ide-dap/src/session.rs b/crates/ide-dap/src/session.rs index 27ac375b..6219f4bf 100644 --- a/crates/ide-dap/src/session.rs +++ b/crates/ide-dap/src/session.rs @@ -422,7 +422,12 @@ pub(crate) fn run_in_terminal(s: &Arc, args: &Value) -> Result IdeResult { fn apply_env(cmd: &mut Command, env: &LaunchEnv) { if env.clear { cmd.env_clear(); + } else { + cmd.envs(ide_core::process::shell_env()); } cmd.envs(&env.vars); } diff --git a/src-tauri/src/shell_env.rs b/src-tauri/src/shell_env.rs index 7be13170..d00d2a67 100644 --- a/src-tauri/src/shell_env.rs +++ b/src-tauri/src/shell_env.rs @@ -5,6 +5,13 @@ //! Spawning an interactive login shell costs ~1 s with typical rc files, so the //! value is cached: a warm start uses the cached PATH immediately and refreshes //! the cache in the background for the next launch. +//! +//! The rest of the shell's environment (API keys, provider switches, SDK homes) +//! goes to child processes through `ide_core::process::shell_env`. It stays in +//! memory, never in the cache: it may hold secrets. + +#[cfg(unix)] +use std::ffi::{OsStr, OsString}; #[cfg(unix)] /// Returns a refresh to run once the environment is final (it spawns a @@ -22,7 +29,9 @@ pub fn inherit_login_shell_path(identifier: &str) -> Option path, None => { // Slow shell rc files must not delay the first start indefinitely. - let fresh = read_login_path(Duration::from_secs(3))?; + let env = read_login_env(Duration::from_secs(3))?; + let fresh = login_path(&env)?; + ide_core::process::set_shell_env(env); if let Some(cache) = &cache { write_cache(cache, &fresh); } @@ -34,9 +43,11 @@ pub fn inherit_login_shell_path(identifier: &str) -> Option Option { +fn login_path(env: &[(OsString, OsString)]) -> Option { + env.iter().find(|(k, _)| k == "PATH").and_then(|(_, v)| v.to_str()).filter(|p| !p.is_empty()).map(str::to_owned) +} + +/// Variables the shell sets for itself rather than the user's configuration. +#[cfg(unix)] +const SHELL_OWN: &[&str] = &["PWD", "OLDPWD", "SHLVL", "_"]; + +/// The login shell's environment; PATH included, the shell's own bookkeeping left out. +#[cfg(unix)] +fn read_login_env(timeout: std::time::Duration) -> Option> { use std::io::Read; + use std::os::unix::ffi::OsStrExt; use std::process::{Command, Stdio}; use std::time::Instant; let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".into()); - const MARK: &str = "__OPENIDEA_PATH__"; + const MARK: &str = "__OPENIDEA_ENV__"; let mut child = Command::new(&shell) - .args(["-ilc", &format!("printf '{MARK}%s{MARK}' \"$PATH\"")]) + .args(["-ilc", &format!("printf '{MARK}'; /usr/bin/env -0; printf '{MARK}'")]) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::null()) .spawn() .ok()?; + // Read while waiting: a large environment would fill the pipe and stall the shell. + let mut stdout = child.stdout.take()?; + let reader = std::thread::spawn(move || { + let mut out = Vec::new(); + stdout.read_to_end(&mut out).map(|_| out) + }); let deadline = Instant::now() + timeout; loop { match child.try_wait() { @@ -71,14 +99,24 @@ fn read_login_path(timeout: std::time::Duration) -> Option { Ok(None) if Instant::now() < deadline => std::thread::sleep(std::time::Duration::from_millis(20)), _ => { let _ = child.kill(); - tracing::warn!("timed out reading PATH from {shell}"); + tracing::warn!("timed out reading the environment from {shell}"); return None; } } } - let mut out = String::new(); - child.stdout.take()?.read_to_string(&mut out).ok()?; - out.split(MARK).nth(1).filter(|p| !p.is_empty()).map(str::to_owned) + let out = reader.join().ok()?.ok()?; + let mark = MARK.as_bytes(); + let start = out.windows(mark.len()).position(|w| w == mark)? + mark.len(); + let len = out[start..].windows(mark.len()).position(|w| w == mark)?; + let vars = out[start..start + len] + .split(|&b| b == 0) + .filter_map(|entry| { + let eq = entry.iter().position(|&b| b == b'=')?; + let (k, v) = (OsStr::from_bytes(&entry[..eq]), OsStr::from_bytes(&entry[eq + 1..])); + (!k.is_empty() && !SHELL_OWN.iter().any(|s| k == *s)).then(|| (k.to_owned(), v.to_owned())) + }) + .collect(); + Some(vars) } #[cfg(not(unix))]