Skip to main content

boltz_util/
shell_env.rs

1use std::path::Path;
2
3use anyhow::{Context as _, Result};
4use collections::HashMap;
5use serde::Deserialize;
6
7use crate::shell::ShellKind;
8
9fn parse_env_map_from_noisy_output(output: &str) -> Result<collections::HashMap<String, String>> {
10    for (position, _) in output.match_indices('{') {
11        let candidate = &output[position..];
12        let mut deserializer = serde_json::Deserializer::from_str(candidate);
13        if let Ok(env_map) = HashMap::<String, String>::deserialize(&mut deserializer) {
14            return Ok(env_map);
15        }
16    }
17    anyhow::bail!("Failed to find JSON in shell output: {output}")
18}
19
20pub fn print_env() {
21    let env_vars: HashMap<String, String> = std::env::vars().collect();
22    let json = serde_json::to_string_pretty(&env_vars).unwrap_or_else(|err| {
23        eprintln!("Error serializing environment variables: {}", err);
24        std::process::exit(1);
25    });
26    println!("{}", json);
27}
28
29/// Capture all environment variables from the login shell in the given directory.
30pub async fn capture(
31    shell_path: impl AsRef<Path>,
32    args: &[String],
33    directory: impl AsRef<Path>,
34) -> Result<collections::HashMap<String, String>> {
35    #[cfg(windows)]
36    return capture_windows(shell_path.as_ref(), args, directory.as_ref()).await;
37    #[cfg(unix)]
38    return capture_unix(shell_path.as_ref(), args, directory.as_ref()).await;
39}
40
41/// Try to parse the environment output before checking the exit status.
42/// The user's shell rc files may contain commands that fail (e.g. editor
43/// integrations that call posix_spawnp outside a real PTY), causing a
44/// non-zero exit status even though `app --printenv` ran successfully and
45/// produced valid output on its separate fd.
46fn parse_env_output(
47    env_output: &str,
48    status: &std::process::ExitStatus,
49    successful_capture_warning: impl FnOnce() -> String,
50    failed_capture_error: impl FnOnce() -> String,
51) -> Result<collections::HashMap<String, String>> {
52    match parse_env_map_from_noisy_output(env_output) {
53        Ok(env_map) => {
54            if !status.success() {
55                log::warn!("{}", successful_capture_warning());
56            }
57            Ok(env_map)
58        }
59        Err(parse_error) => {
60            if !status.success() {
61                anyhow::bail!(
62                    "{}. Failed to deserialize environment variables from json: {parse_error}. output: {env_output}",
63                    failed_capture_error(),
64                );
65            }
66
67            anyhow::bail!(
68                "Failed to deserialize environment variables from json: {parse_error}. output: {env_output}"
69            );
70        }
71    }
72}
73
74#[cfg(unix)]
75async fn capture_unix(
76    shell_path: &Path,
77    args: &[String],
78    directory: &Path,
79) -> Result<collections::HashMap<String, String>> {
80    use std::os::unix::process::CommandExt;
81
82    use crate::command::new_std_command;
83
84    let shell_kind = ShellKind::new(shell_path, false);
85    let quoted_app_path = super::get_shell_safe_app_path(shell_kind)?;
86
87    let mut command_string = String::new();
88    let mut command = new_std_command(shell_path);
89    command.args(args);
90    // In some shells, file descriptors greater than 2 cannot be used in interactive mode,
91    // so file descriptor 0 (stdin) is used instead. This impacts zsh, old bash; perhaps others.
92    const FD_STDIN: std::os::fd::RawFd = 0;
93    const FD_STDOUT: std::os::fd::RawFd = 1;
94    const FD_STDERR: std::os::fd::RawFd = 2;
95
96    let (fd_num, redir) = match shell_kind {
97        ShellKind::Rc => (FD_STDIN, format!(">[1={}]", FD_STDIN)), // `[1=0]`
98        ShellKind::Nushell | ShellKind::Tcsh => (FD_STDOUT, "".to_string()),
99        // xonsh doesn't support redirecting to stdin, and control sequences are printed to
100        // stdout on startup
101        ShellKind::Xonsh => (FD_STDERR, "o>e".to_string()),
102        ShellKind::PowerShell => (FD_STDIN, format!(">{}", FD_STDIN)),
103        _ => (FD_STDIN, format!(">&{}", FD_STDIN)), // `>&0`
104    };
105
106    match shell_kind {
107        ShellKind::Csh | ShellKind::Tcsh => {
108            // For csh/tcsh, login shell requires passing `-` as 0th argument (instead of `-l`)
109            command.arg0("-");
110        }
111        ShellKind::Fish => {
112            // in fish, asdf, direnv attach to the `fish_prompt` event
113            command_string.push_str("emit fish_prompt;");
114            command.arg("-l");
115        }
116        _ => {
117            command.arg("-l");
118        }
119    }
120
121    match shell_kind {
122        // Nushell does not allow non-interactive login shells.
123        // Instead of doing "-l -i -c '<command>'"
124        // use "-l -e '<command>; exit'" instead
125        ShellKind::Nushell => command.arg("-e"),
126        _ => command.args(["-i", "-c"]),
127    };
128
129    // Prefix with "./" if the path starts with "-" to prevent cd from interpreting it as a flag
130    let dir_str = directory.to_string_lossy();
131    let dir_str = if dir_str.starts_with('-') {
132        format!("./{dir_str}").into()
133    } else {
134        dir_str
135    };
136    let quoted_dir = shell_kind
137        .try_quote(&dir_str)
138        .context("unexpected null in directory name")?;
139
140    // cd into the directory, triggering directory specific side-effects (asdf, direnv, etc)
141    command_string.push_str(&format!("cd {};", quoted_dir));
142    if let Some(prefix) = shell_kind.command_prefix() {
143        command_string.push(prefix);
144    }
145    command_string.push_str(&format!("{} --printenv {}", quoted_app_path, redir));
146
147    if let ShellKind::Nushell = shell_kind {
148        command_string.push_str("; exit");
149    }
150
151    command.arg(&command_string);
152
153    super::set_pre_exec_to_start_new_session(&mut command);
154
155    let (env_output, process_output) = spawn_and_read_fd(command, fd_num).await?;
156    let env_output = String::from_utf8_lossy(&env_output);
157
158    parse_env_output(
159        &env_output,
160        &process_output.status,
161        || {
162            format!(
163                "login shell exited with {} but environment was captured successfully. stderr: {:?}",
164                process_output.status,
165                String::from_utf8_lossy(&process_output.stderr),
166            )
167        },
168        || {
169            format!(
170                "login shell exited with {}. stdout: {:?}, stderr: {:?}",
171                process_output.status,
172                String::from_utf8_lossy(&process_output.stdout),
173                String::from_utf8_lossy(&process_output.stderr),
174            )
175        },
176    )
177}
178
179#[cfg(unix)]
180async fn spawn_and_read_fd(
181    mut command: std::process::Command,
182    child_fd: std::os::fd::RawFd,
183) -> anyhow::Result<(Vec<u8>, std::process::Output)> {
184    use command_fds::{CommandFdExt, FdMapping};
185    use std::{io::Read, process::Stdio};
186
187    let (mut reader, writer) = std::io::pipe()?;
188
189    command.fd_mappings(vec![FdMapping {
190        parent_fd: writer.into(),
191        child_fd,
192    }])?;
193
194    let process = smol::process::Command::from(command)
195        .stdin(Stdio::null())
196        .stdout(Stdio::piped())
197        .stderr(Stdio::piped())
198        .spawn()?;
199
200    let mut buffer = Vec::new();
201    reader.read_to_end(&mut buffer)?;
202
203    Ok((buffer, process.output().await?))
204}
205
206#[cfg(windows)]
207async fn capture_windows(
208    shell_path: &Path,
209    args: &[String],
210    directory: &Path,
211) -> Result<collections::HashMap<String, String>> {
212    use std::process::Stdio;
213
214    let app_path =
215        std::env::current_exe().context("Failed to determine current app executable path.")?;
216
217    let shell_kind = ShellKind::new(shell_path, true);
218    // Prefix with "./" if the path starts with "-" to prevent cd from interpreting it as a flag
219    let directory_string = directory.display().to_string();
220    let directory_string = if directory_string.starts_with('-') {
221        format!("./{directory_string}")
222    } else {
223        directory_string
224    };
225    let app_path_string = app_path.display().to_string();
226    let quote_for_shell = |value: &str| {
227        shell_kind
228            .try_quote(value)
229            .map(|quoted| quoted.into_owned())
230            .context("unexpected null in directory name")
231    };
232    let mut cmd = crate::command::new_command(shell_path);
233    cmd.args(args);
234    let quoted_directory = quote_for_shell(&directory_string)?;
235    let quoted_app_path = quote_for_shell(&app_path_string)?;
236    let cmd = match shell_kind {
237        ShellKind::Csh
238        | ShellKind::Tcsh
239        | ShellKind::Rc
240        | ShellKind::Fish
241        | ShellKind::Xonsh
242        | ShellKind::Posix => cmd.args([
243            "-l",
244            "-i",
245            "-c",
246            &format!("cd {}; {} --printenv", quoted_directory, quoted_app_path),
247        ]),
248        ShellKind::PowerShell | ShellKind::Pwsh => cmd.args([
249            "-NonInteractive",
250            "-NoProfile",
251            "-Command",
252            &format!(
253                "Set-Location {}; & {} --printenv",
254                quoted_directory, quoted_app_path
255            ),
256        ]),
257        ShellKind::Elvish => cmd.args([
258            "-c",
259            &format!("cd {}; {} --printenv", quoted_directory, quoted_app_path),
260        ]),
261        ShellKind::Nushell => {
262            let app_command = shell_kind
263                .prepend_command_prefix(&quoted_app_path)
264                .into_owned();
265            cmd.args([
266                "-c",
267                &format!("cd {}; {} --printenv", quoted_directory, app_command),
268            ])
269        }
270        ShellKind::Cmd => {
271            let dir = directory_string.trim_end_matches('\\');
272            cmd.args(["/d", "/c", "cd", dir, "&&", &app_path_string, "--printenv"])
273        }
274    }
275    .stdin(Stdio::null())
276    .stdout(Stdio::piped())
277    .stderr(Stdio::piped());
278    let output = cmd
279        .output()
280        .await
281        .with_context(|| format!("command {cmd:?}"))?;
282    let env_output = String::from_utf8_lossy(&output.stdout);
283
284    parse_env_output(
285        &env_output,
286        &output.status,
287        || {
288            format!(
289                "Command {cmd:?} exited with {} but environment was captured successfully. stderr: {:?}",
290                output.status,
291                String::from_utf8_lossy(&output.stderr),
292            )
293        },
294        || {
295            format!(
296                "Command {cmd:?} failed with {}. stdout: {:?}, stderr: {:?}",
297                output.status,
298                String::from_utf8_lossy(&output.stdout),
299                String::from_utf8_lossy(&output.stderr),
300            )
301        },
302    )
303}
304
305#[cfg(test)]
306mod tests {
307    use std::process::ExitStatus;
308
309    use super::*;
310    use crate::path;
311
312    #[cfg(unix)]
313    fn exit_status(code: i32) -> ExitStatus {
314        use std::os::unix::process::ExitStatusExt;
315
316        ExitStatus::from_raw(code << 8)
317    }
318
319    #[cfg(windows)]
320    fn exit_status(code: u32) -> ExitStatus {
321        use std::os::windows::process::ExitStatusExt;
322
323        ExitStatus::from_raw(code)
324    }
325
326    #[test]
327    fn parse_env_output_accepts_valid_env_when_shell_exits_nonzero() {
328        let env_json = serde_json::json!({
329            "PATH": path!("/usr/bin"),
330            "SHELL": path!("/bin/zsh"),
331        });
332        let env_output = format!("shell startup noise\n{env_json}\nshell shutdown noise");
333
334        let env_map = parse_env_output(
335            &env_output,
336            &exit_status(1),
337            || "shell exited with 1 but environment was captured successfully".to_string(),
338            || panic!("failed capture error should not be evaluated for valid environment output"),
339        )
340        .expect("valid environment output should be returned despite non-zero shell exit");
341        assert_eq!(
342            env_map.get("PATH").map(String::as_str),
343            Some(path!("/usr/bin"))
344        );
345        assert_eq!(
346            env_map.get("SHELL").map(String::as_str),
347            Some(path!("/bin/zsh"))
348        );
349    }
350}