diff --git a/internal/devbox/envvars.go b/internal/devbox/envvars.go index 307546f30e0..d92497945b6 100644 --- a/internal/devbox/envvars.go +++ b/internal/devbox/envvars.go @@ -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") } diff --git a/internal/devbox/envvars_test.go b/internal/devbox/envvars_test.go index d387feb8c19..1182dbee2b2 100644 --- a/internal/devbox/envvars_test.go +++ b/internal/devbox/envvars_test.go @@ -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",