shell_env.rs

  1#![cfg_attr(not(unix), allow(unused))]
  2
  3use anyhow::{Context as _, Result};
  4use std::borrow::Cow;
  5
  6/// Capture all environment variables from the login shell.
  7#[cfg(unix)]
  8pub fn capture(directory: &std::path::Path) -> Result<collections::HashMap<String, String>> {
  9    use std::os::unix::process::CommandExt;
 10    use std::process::Stdio;
 11
 12    let shell_path = std::env::var("SHELL").map(std::path::PathBuf::from)?;
 13    let shell_name = shell_path.file_name().and_then(std::ffi::OsStr::to_str);
 14
 15    let mut command = std::process::Command::new(&shell_path);
 16    command.stdin(Stdio::null());
 17    command.stdout(Stdio::piped());
 18    command.stderr(Stdio::piped());
 19
 20    let mut command_string = String::new();
 21
 22    // What we're doing here is to spawn a shell and then `cd` into
 23    // the project directory to get the env in there as if the user
 24    // `cd`'d into it. We do that because tools like direnv, asdf, ...
 25    // hook into `cd` and only set up the env after that.
 26    command_string.push_str(&format!("cd '{}';", directory.display()));
 27
 28    // In certain shells we need to execute additional_command in order to
 29    // trigger the behavior of direnv, etc.
 30    command_string.push_str(match shell_name {
 31        Some("fish") => "emit fish_prompt;",
 32        _ => "",
 33    });
 34
 35    // In some shells, file descriptors greater than 2 cannot be used in interactive mode,
 36    // so file descriptor 0 is used instead.
 37    const ENV_OUTPUT_FD: std::os::fd::RawFd = 0;
 38    command_string.push_str(&format!("sh -c 'export -p >&{ENV_OUTPUT_FD}';"));
 39
 40    // For csh/tcsh, the login shell option is set by passing `-` as
 41    // the 0th argument instead of using `-l`.
 42    if let Some("tcsh" | "csh") = shell_name {
 43        command.arg0("-");
 44    } else {
 45        command.arg("-l");
 46    }
 47
 48    command.args(["-i", "-c", &command_string]);
 49
 50    super::set_pre_exec_to_start_new_session(&mut command);
 51
 52    let (env_output, process_output) = spawn_and_read_fd(command, ENV_OUTPUT_FD)?;
 53    let env_output = String::from_utf8_lossy(&env_output);
 54
 55    anyhow::ensure!(
 56        process_output.status.success(),
 57        "login shell exited with {}. stdout: {:?}, stderr: {:?}",
 58        process_output.status,
 59        String::from_utf8_lossy(&process_output.stdout),
 60        String::from_utf8_lossy(&process_output.stderr),
 61    );
 62
 63    parse(&env_output)
 64        .filter_map(|entry| match entry {
 65            Ok((name, value)) => Some(Ok((name.into(), value?.into()))),
 66            Err(err) => Some(Err(err)),
 67        })
 68        .collect::<Result<_>>()
 69}
 70
 71#[cfg(unix)]
 72fn spawn_and_read_fd(
 73    mut command: std::process::Command,
 74    child_fd: std::os::fd::RawFd,
 75) -> anyhow::Result<(Vec<u8>, std::process::Output)> {
 76    use command_fds::{CommandFdExt, FdMapping};
 77    use std::io::Read;
 78
 79    let (mut reader, writer) = std::io::pipe()?;
 80
 81    command.fd_mappings(vec![FdMapping {
 82        parent_fd: writer.into(),
 83        child_fd,
 84    }])?;
 85
 86    let process = command.spawn()?;
 87    drop(command);
 88
 89    let mut buffer = Vec::new();
 90    reader.read_to_end(&mut buffer)?;
 91
 92    Ok((buffer, process.wait_with_output()?))
 93}
 94
 95/// Parse the result of calling `sh -c 'export -p'`.
 96///
 97/// https://www.man7.org/linux/man-pages/man1/export.1p.html
 98fn parse(mut input: &str) -> impl Iterator<Item = Result<(Cow<'_, str>, Option<Cow<'_, str>>)>> {
 99    std::iter::from_fn(move || {
100        if input.is_empty() {
101            return None;
102        }
103        match parse_declaration(input) {
104            Ok((entry, rest)) => {
105                input = rest;
106                Some(Ok(entry))
107            }
108            Err(err) => Some(Err(err)),
109        }
110    })
111}
112
113fn parse_declaration(input: &str) -> Result<((Cow<'_, str>, Option<Cow<'_, str>>), &str)> {
114    let rest = input
115        .strip_prefix("export ")
116        .context("expected 'export ' prefix")?;
117
118    if let Some((name, rest)) = parse_name_and_terminator(rest, '\n') {
119        Ok(((name, None), rest))
120    } else {
121        let (name, rest) = parse_name_and_terminator(rest, '=').context("invalid name")?;
122        let (value, rest) = parse_literal_and_terminator(rest, '\n').context("invalid value")?;
123        Ok(((name, Some(value)), rest))
124    }
125}
126
127fn parse_name_and_terminator(input: &str, terminator: char) -> Option<(Cow<'_, str>, &str)> {
128    let (name, rest) = parse_literal_and_terminator(input, terminator)?;
129    (!name.is_empty() && !name.contains('=')).then_some((name, rest))
130}
131
132fn parse_literal_and_terminator(input: &str, terminator: char) -> Option<(Cow<'_, str>, &str)> {
133    if let Some((literal, rest)) = parse_literal_ansi_c_quoted(input) {
134        let rest = rest.strip_prefix(terminator)?;
135        Some((Cow::Owned(literal), rest))
136    } else if let Some((literal, rest)) = parse_literal_single_quoted(input) {
137        let rest = rest.strip_prefix(terminator)?;
138        Some((Cow::Borrowed(literal), rest))
139    } else if let Some((literal, rest)) = parse_literal_double_quoted(input) {
140        let rest = rest.strip_prefix(terminator)?;
141        Some((Cow::Owned(literal), rest))
142    } else {
143        let (literal, rest) = input.split_once(terminator)?;
144        (!literal.contains(|c: char| c.is_ascii_whitespace()))
145            .then_some((Cow::Borrowed(literal), rest))
146    }
147}
148
149/// https://www.gnu.org/software/bash/manual/html_node/ANSI_002dC-Quoting.html
150fn parse_literal_ansi_c_quoted(input: &str) -> Option<(String, &str)> {
151    let rest = input.strip_prefix("$'")?;
152
153    let mut char_indices = rest.char_indices();
154    let mut escaping = false;
155    let (literal, rest) = loop {
156        let (index, char) = char_indices.next()?;
157        if char == '\'' && !escaping {
158            break (&rest[..index], &rest[index + 1..]);
159        } else {
160            escaping = !escaping && char == '\\';
161        }
162    };
163
164    let mut result = String::new();
165    let mut chars = literal.chars();
166    while let Some(ch) = chars.next() {
167        if ch == '\\' {
168            match chars.next() {
169                Some('n') => result.push('\n'),
170                Some('t') => result.push('\t'),
171                Some('r') => result.push('\r'),
172                Some('\\') => result.push('\\'),
173                Some('\'') => result.push('\''),
174                Some('"') => result.push('"'),
175                Some('a') => result.push('\x07'), // bell
176                Some('b') => result.push('\x08'), // backspace
177                Some('f') => result.push('\x0C'), // form feed
178                Some('v') => result.push('\x0B'), // vertical tab
179                Some('0') => result.push('\0'),   // null
180                Some(other) => {
181                    // For unknown escape sequences, keep the backslash and character
182                    result.push('\\');
183                    result.push(other);
184                }
185                None => result.push('\\'), // trailing backslash
186            }
187        } else {
188            result.push(ch);
189        }
190    }
191
192    Some((result, rest))
193}
194
195/// https://www.gnu.org/software/bash/manual/html_node/Single-Quotes.html
196fn parse_literal_single_quoted(input: &str) -> Option<(&str, &str)> {
197    input.strip_prefix('\'')?.split_once('\'')
198}
199
200/// https://www.gnu.org/software/bash/manual/html_node/Double-Quotes.html
201fn parse_literal_double_quoted(input: &str) -> Option<(String, &str)> {
202    let rest = input.strip_prefix('"')?;
203
204    let mut char_indices = rest.char_indices();
205    let mut escaping = false;
206    let (literal, rest) = loop {
207        let (index, char) = char_indices.next()?;
208        if char == '"' && !escaping {
209            break (&rest[..index], &rest[index + 1..]);
210        } else {
211            escaping = !escaping && char == '\\';
212        }
213    };
214
215    let literal = literal
216        .replace("\\$", "$")
217        .replace("\\`", "`")
218        .replace("\\\"", "\"")
219        .replace("\\\n", "")
220        .replace("\\\\", "\\");
221
222    Some((literal, rest))
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    #[cfg(unix)]
230    #[test]
231    fn test_spawn_and_read_fd() -> anyhow::Result<()> {
232        let mut command = std::process::Command::new("sh");
233        super::super::set_pre_exec_to_start_new_session(&mut command);
234        command.args(["-lic", "printf 'abc%.0s' $(seq 1 65536) >&0"]);
235        let (bytes, _) = spawn_and_read_fd(command, 0)?;
236        assert_eq!(bytes.len(), 65536 * 3);
237        Ok(())
238    }
239
240    #[test]
241    fn test_parse() {
242        let input = indoc::indoc! {r#"
243        export foo
244        export 'foo'
245        export "foo"
246        export foo=
247        export 'foo'=
248        export "foo"=
249        export foo=bar
250        export foo='bar'
251        export foo="bar"
252        export foo='b
253        a
254        z'
255        export foo="b
256        a
257        z"
258        export foo='b\
259        a\
260        z'
261        export foo="b\
262        a\
263        z"
264        export foo='\`Hello\`
265        \"wo\
266        rld\"\n!\\
267        !'
268        export foo="\`Hello\`
269        \"wo\
270        rld\"\n!\\
271        !"
272        export foo=$'hello\nworld'
273        "#};
274
275        let expected_values = [
276            None,
277            None,
278            None,
279            Some(""),
280            Some(""),
281            Some(""),
282            Some("bar"),
283            Some("bar"),
284            Some("bar"),
285            Some("b\na\nz"),
286            Some("b\na\nz"),
287            Some("b\\\na\\\nz"),
288            Some("baz"),
289            Some(indoc::indoc! {r#"
290            \`Hello\`
291            \"wo\
292            rld\"\n!\\
293            !"#}),
294            Some(indoc::indoc! {r#"
295            `Hello`
296            "world"\n!\!"#}),
297            Some("hello\nworld"),
298        ];
299        let expected = expected_values
300            .into_iter()
301            .map(|value| ("foo".into(), value.map(Into::into)))
302            .collect::<Vec<_>>();
303
304        let actual = parse(input).collect::<Result<Vec<_>>>().unwrap();
305        assert_eq!(expected, actual);
306    }
307
308    #[test]
309    fn test_parse_declaration() {
310        let ((name, value), rest) = parse_declaration("export foo\nrest").unwrap();
311        assert_eq!(name, "foo");
312        assert_eq!(value, None);
313        assert_eq!(rest, "rest");
314
315        let ((name, value), rest) = parse_declaration("export foo=bar\nrest").unwrap();
316        assert_eq!(name, "foo");
317        assert_eq!(value.as_deref(), Some("bar"));
318        assert_eq!(rest, "rest");
319    }
320
321    #[test]
322    fn test_parse_literal_single_quoted() {
323        let input = indoc::indoc! {r#"
324        '\`Hello\`
325        \"wo\
326        rld\"\n!\\
327        !'
328        rest"#};
329
330        let expected = indoc::indoc! {r#"
331        \`Hello\`
332        \"wo\
333        rld\"\n!\\
334        !"#};
335
336        let (actual, rest) = parse_literal_single_quoted(input).unwrap();
337        assert_eq!(expected, actual);
338        assert_eq!(rest, "\nrest");
339    }
340
341    #[test]
342    fn test_parse_literal_double_quoted() {
343        let input = indoc::indoc! {r#"
344        "\`Hello\`
345        \"wo\
346        rld\"\n!\\
347        !"
348        rest"#};
349
350        let expected = indoc::indoc! {r#"
351        `Hello`
352        "world"\n!\!"#};
353
354        let (actual, rest) = parse_literal_double_quoted(input).unwrap();
355        assert_eq!(expected, actual);
356        assert_eq!(rest, "\nrest");
357    }
358
359    #[test]
360    fn test_parse_literal_ansi_c_quoted() {
361        let (actual, rest) = parse_literal_ansi_c_quoted("$'hello\\nworld'\nrest").unwrap();
362        assert_eq!(actual, "hello\nworld");
363        assert_eq!(rest, "\nrest");
364
365        let (actual, rest) = parse_literal_ansi_c_quoted("$'tab\\there'\nrest").unwrap();
366        assert_eq!(actual, "tab\there");
367        assert_eq!(rest, "\nrest");
368
369        let (actual, rest) = parse_literal_ansi_c_quoted("$'quote\\'\\'end'\nrest").unwrap();
370        assert_eq!(actual, "quote''end");
371        assert_eq!(rest, "\nrest");
372
373        let (actual, rest) = parse_literal_ansi_c_quoted("$'backslash\\\\end'\nrest").unwrap();
374        assert_eq!(actual, "backslash\\end");
375        assert_eq!(rest, "\nrest");
376    }
377
378    #[test]
379    fn test_parse_buildphase_export() {
380        let input = r#"export buildPhase=$'{ echo "------------------------------------------------------------";\n  echo " WARNING: the existence of this path is not guaranteed.";\n  echo " It is an internal implementation detail for pkgs.mkShell.";\n  echo "------------------------------------------------------------";\n  echo;\n  # Record all build inputs as runtime dependencies\n  export;\n} >> "$out"\n'
381"#;
382
383        let expected_value = r#"{ echo "------------------------------------------------------------";
384  echo " WARNING: the existence of this path is not guaranteed.";
385  echo " It is an internal implementation detail for pkgs.mkShell.";
386  echo "------------------------------------------------------------";
387  echo;
388  # Record all build inputs as runtime dependencies
389  export;
390} >> "$out"
391"#;
392
393        let ((name, value), _rest) = parse_declaration(input).unwrap();
394        assert_eq!(name, "buildPhase");
395        assert_eq!(value.as_deref(), Some(expected_value));
396    }
397}