diff --git a/internal/devbox/envvars.go b/internal/devbox/envvars.go index ec8231e19ab..63d44769ead 100644 --- a/internal/devbox/envvars.go +++ b/internal/devbox/envvars.go @@ -45,11 +45,17 @@ func exportify(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 NOT special inside double quotes and must not be + // escaped. Writing a backslash before a newline produces a line + // continuation, which the shell removes entirely, joining adjacent + // lines together (e.g. a multi-line PROMPT_COMMAND would be silently + // mangled). Leaving the newline unescaped preserves it literally. 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) diff --git a/internal/devbox/envvars_test.go b/internal/devbox/envvars_test.go new file mode 100644 index 00000000000..d7704ab92c2 --- /dev/null +++ b/internal/devbox/envvars_test.go @@ -0,0 +1,44 @@ +// Copyright 2024 Jetify Inc. and contributors. All rights reserved. +// Use of this source code is governed by the license in the LICENSE file. + +package devbox + +import "testing" + +func TestExportify(t *testing.T) { + tests := []struct { + name string + vars map[string]string + want string + }{ + { + name: "simple value", + vars: map[string]string{"FOO": "bar"}, + want: `export FOO="bar";`, + }, + { + name: "escapes shell-special characters", + vars: map[string]string{"FOO": "a$b`c\"d\\e"}, + want: `export FOO="a\$b\` + "`" + `c\"d\\e";`, + }, + { + // Regression test for #2814: a multi-line value (e.g. a + // PROMPT_COMMAND set by bash-preexec) must keep its newlines + // literal. Escaping a newline with a backslash produces a line + // continuation that the shell removes, concatenating the lines and + // corrupting the value. + name: "preserves embedded newlines without escaping", + vars: map[string]string{"PROMPT_COMMAND": "__bp_precmd_invoke_cmd\ndbus-send >/dev/null 2>&1\n__bp_interactive_mode"}, + want: "export PROMPT_COMMAND=\"__bp_precmd_invoke_cmd\ndbus-send >/dev/null 2>&1\n__bp_interactive_mode\";", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := exportify(tt.vars) + if got != tt.want { + t.Errorf("exportify() =\n%q\nwant\n%q", got, tt.want) + } + }) + } +}