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}