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
15 changes: 11 additions & 4 deletions internal/devbox/envvars.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,14 +89,21 @@ 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 {
// Note: a newline is NOT escaped. Inside double quotes a bare
// newline is already a literal newline, whereas a backslash
// followed by a newline is a line continuation that the shell
// removes entirely. Escaping newlines would collapse a multi-line
// value onto a single line and silently corrupt it (e.g. a
// PROMPT_COMMAND containing `... 2>&1\n__bp_...` would become
// `... 2>&1__bp_...`, causing an "ambiguous redirect").
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':
case '$', '`', '"', '\\':
strb.WriteRune('\\')
}
strb.WriteRune(r)
strb.WriteRune(char)
}
strb.WriteString("\";\n")
}
Expand Down
23 changes: 23 additions & 0 deletions internal/devbox/envvars_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,29 @@ func TestExportifySkipsInvalidNames(t *testing.T) {
}
}

// TestExportifyPreservesNewlines ensures that a value containing newlines is
// emitted with literal (unescaped) newlines. Escaping a newline as `\<newline>`
// makes the shell treat it as a line continuation and strip it, collapsing a
// multi-line value onto a single line. That corrupted, for example, a
// PROMPT_COMMAND whose `... 2>&1` and `__bp_interactive_mode` lines got joined
// into `... 2>&1__bp_interactive_mode`, yielding a "1__bp_...: ambiguous
// redirect" error at every prompt. See issue #2814.
func TestExportifyPreservesNewlines(t *testing.T) {
value := "cmd_a >/dev/null 2>&1\n__bp_interactive_mode"
got := exportify(io.Discard, map[string]string{"PROMPT_COMMAND": value})

want := "export PROMPT_COMMAND=\"cmd_a >/dev/null 2>&1\n__bp_interactive_mode\";"
if got != want {
t.Errorf("exportify newline handling:\n got: %q\nwant: %q", got, want)
}

// A backslash-newline (line continuation) must not appear: that is the
// exact corruption this test guards against.
if strings.Contains(got, "\\\n") {
t.Errorf("exportify escaped a newline as a line continuation, corrupting the value:\n%s", got)
}
}

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