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:
parent
8227c45a11
commit
55120c4231
5 changed files with 303 additions and 150 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
@ -17129,6 +17129,7 @@ dependencies = [
|
|||
"futures-lite 1.13.0",
|
||||
"git2",
|
||||
"globset",
|
||||
"indoc",
|
||||
"itertools 0.14.0",
|
||||
"libc",
|
||||
"log",
|
||||
|
|
|
@ -245,108 +245,39 @@ async fn load_shell_environment(
|
|||
Option<HashMap<String, String>>,
|
||||
Option<EnvironmentErrorMessage>,
|
||||
) {
|
||||
use crate::direnv::{DirenvError, load_direnv_environment};
|
||||
use std::path::PathBuf;
|
||||
use util::parse_env_output;
|
||||
use crate::direnv::load_direnv_environment;
|
||||
use util::shell_env;
|
||||
|
||||
fn message<T>(with: &str) -> (Option<T>, Option<EnvironmentErrorMessage>) {
|
||||
let message = EnvironmentErrorMessage::from_str(with);
|
||||
(None, Some(message))
|
||||
}
|
||||
|
||||
const MARKER: &str = "ZED_SHELL_START";
|
||||
let Some(shell) = std::env::var("SHELL").log_err() else {
|
||||
return message("Failed to get login environment. SHELL environment variable is not set");
|
||||
let dir_ = dir.to_owned();
|
||||
let mut envs = match smol::unblock(move || shell_env::capture(Some(dir_))).await {
|
||||
Ok(envs) => envs,
|
||||
Err(err) => {
|
||||
util::log_err(&err);
|
||||
return (
|
||||
None,
|
||||
Some(EnvironmentErrorMessage::from_str(
|
||||
"Failed to load environment variables. See log for details",
|
||||
)),
|
||||
);
|
||||
}
|
||||
};
|
||||
let shell_path = PathBuf::from(&shell);
|
||||
let shell_name = shell_path.file_name().and_then(|f| f.to_str());
|
||||
|
||||
// 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 the user selects `Direct` for direnv, it would set an environment
|
||||
// variable that later uses to know that it should not run the hook.
|
||||
// We would include in `.envs` call so it is okay to run the hook
|
||||
// even if direnv direct mode is enabled.
|
||||
//
|
||||
// In certain shells we need to execute additional_command in order to
|
||||
// trigger the behavior of direnv, etc.
|
||||
|
||||
let command = match shell_name {
|
||||
Some("fish") => format!(
|
||||
"cd '{}'; emit fish_prompt; printf '%s' {MARKER}; /usr/bin/env;",
|
||||
dir.display()
|
||||
),
|
||||
_ => format!(
|
||||
"cd '{}'; printf '%s' {MARKER}; /usr/bin/env;",
|
||||
dir.display()
|
||||
),
|
||||
};
|
||||
|
||||
// csh/tcsh only supports `-l` if it's the only flag. So this won't be a login shell.
|
||||
// Users must rely on vars from `~/.tcshrc` or `~/.cshrc` and not `.login` as a result.
|
||||
let args = match shell_name {
|
||||
Some("tcsh") | Some("csh") => vec!["-i".to_string(), "-c".to_string(), command],
|
||||
_ => vec![
|
||||
"-l".to_string(),
|
||||
"-i".to_string(),
|
||||
"-c".to_string(),
|
||||
command,
|
||||
],
|
||||
};
|
||||
|
||||
let Some(output) = smol::unblock(move || {
|
||||
util::set_pre_exec_to_start_new_session(std::process::Command::new(&shell).args(&args))
|
||||
.output()
|
||||
})
|
||||
.await
|
||||
.log_err() else {
|
||||
return message(
|
||||
"Failed to spawn login shell to source login environment variables. See logs for details",
|
||||
);
|
||||
};
|
||||
|
||||
if !output.status.success() {
|
||||
log::error!("login shell exited with {}", output.status);
|
||||
return message("Login shell exited with nonzero exit code. See logs for details");
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let Some(env_output_start) = stdout.find(MARKER) else {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
log::error!(
|
||||
"failed to parse output of `env` command in login shell. stdout: {:?}, stderr: {:?}",
|
||||
stdout,
|
||||
stderr
|
||||
);
|
||||
return message("Failed to parse stdout of env command. See logs for the output");
|
||||
};
|
||||
|
||||
let mut parsed_env = HashMap::default();
|
||||
let env_output = &stdout[env_output_start + MARKER.len()..];
|
||||
|
||||
parse_env_output(env_output, |key, value| {
|
||||
parsed_env.insert(key, value);
|
||||
});
|
||||
|
||||
let (direnv_environment, direnv_error) = match load_direnv {
|
||||
DirenvSettings::ShellHook => (None, None),
|
||||
DirenvSettings::Direct => match load_direnv_environment(&parsed_env, dir).await {
|
||||
DirenvSettings::Direct => match load_direnv_environment(&envs, dir).await {
|
||||
Ok(env) => (Some(env), None),
|
||||
Err(err) => (
|
||||
None,
|
||||
<Option<EnvironmentErrorMessage> as From<DirenvError>>::from(err),
|
||||
),
|
||||
Err(err) => (None, err.into()),
|
||||
},
|
||||
};
|
||||
|
||||
for (key, value) in direnv_environment.unwrap_or(HashMap::default()) {
|
||||
parsed_env.insert(key, value);
|
||||
if let Some(direnv_environment) = direnv_environment {
|
||||
envs.extend(direnv_environment);
|
||||
}
|
||||
|
||||
(Some(parsed_env), direnv_error)
|
||||
(Some(envs), direnv_error)
|
||||
}
|
||||
|
||||
fn get_directory_env_impl(
|
||||
|
|
|
@ -50,5 +50,6 @@ dunce = "1.0"
|
|||
|
||||
[dev-dependencies]
|
||||
git2.workspace = true
|
||||
indoc.workspace = true
|
||||
rand.workspace = true
|
||||
util_macros.workspace = true
|
||||
|
|
273
crates/util/src/shell_env.rs
Normal file
273
crates/util/src/shell_env.rs
Normal 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");
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue