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