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 {
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: a literal newline is NOT escaped. Inside double quotes
// a bare newline is preserved verbatim, whereas a backslash
// immediately followed by a newline is a line continuation that
// the shell deletes. Escaping it would join a multi-line value's
// adjacent lines and corrupt values like a multi-command
// PROMPT_COMMAND (see jetify-com/devbox#2814).
case '$', '`', '"', '\\':
strb.WriteRune('\\')
}
strb.WriteRune(r)
strb.WriteRune(char)
}
strb.WriteString("\";\n")
}
Expand Down
21 changes: 21 additions & 0 deletions internal/devbox/envvars_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,27 @@ func TestExportifySkipsInvalidNames(t *testing.T) {
}
}

// TestExportifyPreservesNewlines ensures that multi-line values (e.g. a
// PROMPT_COMMAND made of several commands separated by newlines) are emitted so
// that the shell preserves the newlines. A backslash before a newline would be a
// line continuation that the shell deletes, joining adjacent lines and corrupting
// the value (see jetify-com/devbox#2814).
func TestExportifyPreservesNewlines(t *testing.T) {
value := "__bp_precmd_invoke_cmd\ndbus-send ... >/dev/null 2>&1\n__bp_interactive_mode"
got := exportify(io.Discard, map[string]string{"PROMPT_COMMAND": value})

// The emitted value must contain a bare newline, not an escaped one. If the
// newline were escaped, "2>&1\" and "__bp_interactive_mode" would collapse
// into "2>&1__bp_interactive_mode" when the shell sourced the export.
if strings.Contains(got, "\\\n") {
t.Errorf("newline should not be backslash-escaped, got:\n%q", got)
}
want := "export PROMPT_COMMAND=\"" + value + "\";"
if !strings.Contains(got, want) {
t.Errorf("expected exported value to preserve newlines.\ngot:\n%q\nwant to contain:\n%q", got, want)
}
}

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