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
14 changes: 11 additions & 3 deletions internal/devbox/envvars.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,11 +89,19 @@ func exportify(w io.Writer, vars map[string]string) string {
strb.WriteString("export ")
strb.WriteString(key)
strb.WriteString(`="`)
// Escape the characters that are special inside double quotes:
// https://pubs.opengroup.org/onlinepubs/009604499/utilities/xcu_chap02.html#tag_02_02_03
//
// A newline is intentionally NOT escaped. Inside double quotes a
// literal newline is preserved as-is, but a backslash-newline is a
// line continuation that the shell removes entirely. Escaping it
// would silently join adjacent lines of a multiline value and
// corrupt it (see #2814, where a multiline PROMPT_COMMAND turned
// `2>&1\<newline>foo` into `2>&1foo` and produced an "ambiguous
// redirect" error).
for _, r := range vars[key] {
switch r {
// 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)
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) {
}
}

// TestExportifyMultilineValue ensures that env vars whose values contain
// newlines are emitted with literal newlines inside the double quotes, not a
// backslash-newline. A backslash-newline is a shell line continuation that gets
// removed on eval, which would join adjacent lines and corrupt the value (see
// issue #2814: a multiline PROMPT_COMMAND produced an "ambiguous redirect").
func TestExportifyMultilineValue(t *testing.T) {
got := exportify(io.Discard, map[string]string{
"MULTI": "line1\nline2",
})

want := "export MULTI=\"line1\nline2\";"
if got != want {
t.Errorf("exportify multiline value = %q, want %q", got, want)
}

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

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