Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 13 additions & 4 deletions internal/devbox/envvars.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,14 +89,23 @@ func exportify(w io.Writer, vars map[string]string) string {
strb.WriteString("export ")
strb.WriteString(key)
strb.WriteString(`="`)
for _, r := range vars[key] {
switch r {
for _, char := range vars[key] {
switch char {
// Special characters inside double quotes:
// https://pubs.opengroup.org/onlinepubs/009604499/utilities/xcu_chap02.html#tag_02_02_03
case '$', '`', '"', '\\', '\n':
//
// Note: newline is intentionally NOT escaped. A literal newline
// inside double quotes is preserved as-is, but a backslash
// followed by a newline is a line continuation that the shell
// *removes*, silently joining the two lines. Escaping it would
// corrupt any multi-line value (e.g. a PROMPT_COMMAND that spans
// several lines), producing broken shell such as
// `... 2>&1__bp_interactive_mode` and errors like
// "ambiguous redirect". See issue #2814.
case '$', '`', '"', '\\':
strb.WriteRune('\\')
}
strb.WriteRune(r)
strb.WriteRune(char)
}
strb.WriteString("\";\n")
}
Expand Down
22 changes: 22 additions & 0 deletions internal/devbox/envvars_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,28 @@ func TestExportifySkipsInvalidNames(t *testing.T) {
}
}

// TestExportifyPreservesNewlines ensures multi-line env values (e.g. a
// PROMPT_COMMAND that spans several lines) survive round-tripping through the
// shell. A newline must be emitted literally, not as a backslash-newline: inside
// double quotes the latter is a line continuation that the shell removes, which
// silently joins adjacent lines and corrupts the value. See issue #2814, where a
// multi-line PROMPT_COMMAND collapsed into `... 2>&1__bp_interactive_mode` and
// produced a "bash: ...: ambiguous redirect" error at every prompt.
func TestExportifyPreservesNewlines(t *testing.T) {
value := "line1 >/dev/null 2>&1\n__bp_interactive_mode"
got := exportify(io.Discard, map[string]string{"PROMPT_COMMAND": value})

// The value must be emitted with a literal newline, never a
// backslash-newline (which bash would strip as a line continuation).
if strings.Contains(got, "2>&1\\\n") {
t.Errorf("newline was escaped as a line continuation, which corrupts the value:\n%s", got)
}
want := "export PROMPT_COMMAND=\"line1 >/dev/null 2>&1\n__bp_interactive_mode\";"
if got != want {
t.Errorf("exportify(...) = %q, want %q", got, want)
}
}

func TestExportifyNushellSkipsInvalidNames(t *testing.T) {
got := exportifyNushell(io.Discard, map[string]string{
"GOOD": "value",
Expand Down
Loading