Properly load environment variables from the login shell (#31799)

Fixes #11647
Fixes #13888
Fixes #18771
Fixes #19779
Fixes #22437
Fixes #23649
Fixes #24200
Fixes #27601

Zed’s current method of loading environment variables from the login
shell has two issues:
1. Some shells—​fish in particular—​​write specific escape characters to
`stdout` right before they exit. When this happens, the tail end of the
last environment variable printed by `/usr/bin/env` becomes corrupted.
2. If a multi-line value contains an equals sign, that line is
mis-parsed as a separate name-value pair.

This PR addresses those problems by:
1. Redirecting the shell command's `stdout` directly to a temporary
file, eliminating any side effects caused by the shell itself.
2. Replacing `/usr/bin/env` with `sh -c 'export -p'`, which removes
ambiguity when handling multi-line values.

Additional changes:
- Correctly set the arguments used to launch a login shell under `csh`
or `tcsh`.
- Deduplicate code by sharing the implementation that loads environment
variables on first run with the logic that reloads them for a project.



Release Notes:

- N/A
This commit is contained in:
Haru Kim 2025-06-04 10:16:26 +09:00 committed by GitHub
parent 8227c45a11
commit 55120c4231
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 303 additions and 150 deletions

View file

@ -50,5 +50,6 @@ dunce = "1.0"
[dev-dependencies]
git2.workspace = true
indoc.workspace = true
rand.workspace = true
util_macros.workspace = true

View file

@ -0,0 +1,273 @@
use anyhow::{Context as _, Result};
use collections::HashMap;
use std::borrow::Cow;
use std::ffi::OsStr;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::process::Command;
use tempfile::NamedTempFile;
/// Capture all environment variables from the login shell.
pub fn capture(change_dir: Option<impl AsRef<Path>>) -> Result<HashMap<String, String>> {
let shell_path = std::env::var("SHELL").map(PathBuf::from)?;
let shell_name = shell_path.file_name().and_then(OsStr::to_str);
let mut command_string = String::new();
// What we're doing here is to spawn a shell and then `cd` into
// the project directory to get the env in there as if the user
// `cd`'d into it. We do that because tools like direnv, asdf, ...
// hook into `cd` and only set up the env after that.
if let Some(dir) = change_dir {
let dir_str = dir.as_ref().to_string_lossy();
command_string.push_str(&format!("cd '{dir_str}';"));
}
// In certain shells we need to execute additional_command in order to
// trigger the behavior of direnv, etc.
command_string.push_str(match shell_name {
Some("fish") => "emit fish_prompt;",
_ => "",
});
let mut env_output_file = NamedTempFile::new()?;
command_string.push_str(&format!(
"sh -c 'export -p' > '{}';",
env_output_file.path().to_string_lossy(),
));
let mut command = Command::new(&shell_path);
// For csh/tcsh, the login shell option is set by passing `-` as
// the 0th argument instead of using `-l`.
if let Some("tcsh" | "csh") = shell_name {
#[cfg(unix)]
std::os::unix::process::CommandExt::arg0(&mut command, "-");
} else {
command.arg("-l");
}
command.args(["-i", "-c", &command_string]);
let process_output = super::set_pre_exec_to_start_new_session(&mut command).output()?;
anyhow::ensure!(
process_output.status.success(),
"login shell exited with {}. stdout: {:?}, stderr: {:?}",
process_output.status,
String::from_utf8_lossy(&process_output.stdout),
String::from_utf8_lossy(&process_output.stderr),
);
let mut env_output = String::new();
env_output_file.read_to_string(&mut env_output)?;
parse(&env_output)
.filter_map(|entry| match entry {
Ok((name, value)) => Some(Ok((name.into(), value?.into()))),
Err(err) => Some(Err(err)),
})
.collect::<Result<HashMap<String, String>>>()
}
/// Parse the result of calling `sh -c 'export -p'`.
///
/// https://www.man7.org/linux/man-pages/man1/export.1p.html
fn parse(mut input: &str) -> impl Iterator<Item = Result<(Cow<'_, str>, Option<Cow<'_, str>>)>> {
std::iter::from_fn(move || {
if input.is_empty() {
return None;
}
match parse_declaration(input) {
Ok((entry, rest)) => {
input = rest;
Some(Ok(entry))
}
Err(err) => Some(Err(err)),
}
})
}
fn parse_declaration(input: &str) -> Result<((Cow<'_, str>, Option<Cow<'_, str>>), &str)> {
let rest = input
.strip_prefix("export ")
.context("expected 'export ' prefix")?;
if let Some((name, rest)) = parse_name_and_terminator(rest, '\n') {
Ok(((name, None), rest))
} else {
let (name, rest) = parse_name_and_terminator(rest, '=').context("invalid name")?;
let (value, rest) = parse_literal_and_terminator(rest, '\n').context("invalid value")?;
Ok(((name, Some(value)), rest))
}
}
fn parse_name_and_terminator(input: &str, terminator: char) -> Option<(Cow<'_, str>, &str)> {
let (name, rest) = parse_literal_and_terminator(input, terminator)?;
(!name.is_empty() && !name.contains('=')).then_some((name, rest))
}
fn parse_literal_and_terminator(input: &str, terminator: char) -> Option<(Cow<'_, str>, &str)> {
if let Some((literal, rest)) = parse_literal_single_quoted(input) {
let rest = rest.strip_prefix(terminator)?;
Some((Cow::Borrowed(literal), rest))
} else if let Some((literal, rest)) = parse_literal_double_quoted(input) {
let rest = rest.strip_prefix(terminator)?;
Some((Cow::Owned(literal), rest))
} else {
let (literal, rest) = input.split_once(terminator)?;
(!literal.contains(|c: char| c.is_ascii_whitespace()))
.then_some((Cow::Borrowed(literal), rest))
}
}
/// https://www.gnu.org/software/bash/manual/html_node/Single-Quotes.html
fn parse_literal_single_quoted(input: &str) -> Option<(&str, &str)> {
input.strip_prefix('\'')?.split_once('\'')
}
/// https://www.gnu.org/software/bash/manual/html_node/Double-Quotes.html
fn parse_literal_double_quoted(input: &str) -> Option<(String, &str)> {
let rest = input.strip_prefix('"')?;
let mut char_indices = rest.char_indices();
let mut escaping = false;
let (literal, rest) = loop {
let (index, char) = char_indices.next()?;
if char == '"' && !escaping {
break (&rest[..index], &rest[index + 1..]);
} else {
escaping = !escaping && char == '\\';
}
};
let literal = literal
.replace("\\$", "$")
.replace("\\`", "`")
.replace("\\\"", "\"")
.replace("\\\n", "")
.replace("\\\\", "\\");
Some((literal, rest))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse() {
let input = indoc::indoc! {r#"
export foo
export 'foo'
export "foo"
export foo=
export 'foo'=
export "foo"=
export foo=bar
export foo='bar'
export foo="bar"
export foo='b
a
z'
export foo="b
a
z"
export foo='b\
a\
z'
export foo="b\
a\
z"
export foo='\`Hello\`
\"wo\
rld\"\n!\\
!'
export foo="\`Hello\`
\"wo\
rld\"\n!\\
!"
"#};
let expected_values = [
None,
None,
None,
Some(""),
Some(""),
Some(""),
Some("bar"),
Some("bar"),
Some("bar"),
Some("b\na\nz"),
Some("b\na\nz"),
Some("b\\\na\\\nz"),
Some("baz"),
Some(indoc::indoc! {r#"
\`Hello\`
\"wo\
rld\"\n!\\
!"#}),
Some(indoc::indoc! {r#"
`Hello`
"world"\n!\!"#}),
];
let expected = expected_values
.into_iter()
.map(|value| ("foo".into(), value.map(Into::into)))
.collect::<Vec<_>>();
let actual = parse(input).collect::<Result<Vec<_>>>().unwrap();
assert_eq!(expected, actual);
}
#[test]
fn test_parse_declaration() {
let ((name, value), rest) = parse_declaration("export foo\nrest").unwrap();
assert_eq!(name, "foo");
assert_eq!(value, None);
assert_eq!(rest, "rest");
let ((name, value), rest) = parse_declaration("export foo=bar\nrest").unwrap();
assert_eq!(name, "foo");
assert_eq!(value.as_deref(), Some("bar"));
assert_eq!(rest, "rest");
}
#[test]
fn test_parse_literal_single_quoted() {
let input = indoc::indoc! {r#"
'\`Hello\`
\"wo\
rld\"\n!\\
!'
rest"#};
let expected = indoc::indoc! {r#"
\`Hello\`
\"wo\
rld\"\n!\\
!"#};
let (actual, rest) = parse_literal_single_quoted(input).unwrap();
assert_eq!(expected, actual);
assert_eq!(rest, "\nrest");
}
#[test]
fn test_parse_literal_double_quoted() {
let input = indoc::indoc! {r#"
"\`Hello\`
\"wo\
rld\"\n!\\
!"
rest"#};
let expected = indoc::indoc! {r#"
`Hello`
"world"\n!\!"#};
let (actual, rest) = parse_literal_double_quoted(input).unwrap();
assert_eq!(expected, actual);
assert_eq!(rest, "\nrest");
}
}

View file

@ -5,6 +5,7 @@ pub mod fs;
pub mod markdown;
pub mod paths;
pub mod serde;
pub mod shell_env;
pub mod size;
#[cfg(any(test, feature = "test-support"))]
pub mod test;
@ -27,9 +28,6 @@ use std::{
};
use unicase::UniCase;
#[cfg(unix)]
use anyhow::Context as _;
pub use take_until::*;
#[cfg(any(test, feature = "test-support"))]
pub use util_macros::{line_endings, separator, uri};
@ -312,46 +310,21 @@ fn load_shell_from_passwd() -> Result<()> {
pub fn load_login_shell_environment() -> Result<()> {
load_shell_from_passwd().log_err();
let marker = "ZED_LOGIN_SHELL_START";
let shell = env::var("SHELL").context(
"SHELL environment variable is not assigned so we can't source login environment variables",
)?;
// If possible, we want to `cd` in the user's `$HOME` to trigger programs
// such as direnv, asdf, mise, ... to adjust the PATH. These tools often hook
// into shell's `cd` command (and hooks) to manipulate env.
// We do this so that we get the env a user would have when spawning a shell
// in home directory.
let shell_cmd_prefix = std::env::var_os("HOME")
.and_then(|home| home.into_string().ok())
.map(|home| format!("cd '{home}';"));
let shell_cmd = format!(
"{}printf '%s' {marker}; /usr/bin/env;",
shell_cmd_prefix.as_deref().unwrap_or("")
);
let output = set_pre_exec_to_start_new_session(
std::process::Command::new(&shell).args(["-l", "-i", "-c", &shell_cmd]),
)
.output()
.context("failed to spawn login shell to source login environment variables")?;
anyhow::ensure!(output.status.success(), "login shell exited with error");
let stdout = String::from_utf8_lossy(&output.stdout);
if let Some(env_output_start) = stdout.find(marker) {
let env_output = &stdout[env_output_start + marker.len()..];
parse_env_output(env_output, |key, value| unsafe { env::set_var(key, value) });
log::info!(
"set environment variables from shell:{}, path:{}",
shell,
env::var("PATH").unwrap_or_default(),
);
for (name, value) in shell_env::capture(Some(paths::home_dir()))? {
unsafe { env::set_var(&name, &value) };
}
log::info!(
"set environment variables from shell:{}, path:{}",
std::env::var("SHELL").unwrap_or_default(),
std::env::var("PATH").unwrap_or_default(),
);
Ok(())
}
@ -375,32 +348,6 @@ pub fn set_pre_exec_to_start_new_session(
command
}
/// Parse the result of calling `usr/bin/env` with no arguments
pub fn parse_env_output(env: &str, mut f: impl FnMut(String, String)) {
let mut current_key: Option<String> = None;
let mut current_value: Option<String> = None;
for line in env.split_terminator('\n') {
if let Some(separator_index) = line.find('=') {
if !line[..separator_index].is_empty() {
if let Some((key, value)) = Option::zip(current_key.take(), current_value.take()) {
f(key, value)
}
current_key = Some(line[..separator_index].to_string());
current_value = Some(line[separator_index + 1..].to_string());
continue;
};
}
if let Some(value) = current_value.as_mut() {
value.push('\n');
value.push_str(line);
}
}
if let Some((key, value)) = Option::zip(current_key.take(), current_value.take()) {
f(key, value)
}
}
pub fn merge_json_lenient_value_into(
source: serde_json_lenient::Value,
target: &mut serde_json_lenient::Value,