From 556a4fc163a1ebb6eef3f1ea771852b076a84725 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 3 Jun 2026 14:09:32 +0000 Subject: [PATCH 1/2] fix(shellenv): preserve literal newlines in exported env values exportify escaped newlines in env-var values by writing a backslash before the newline. Inside a bash double-quoted string, a backslash-newline is a line continuation, so the shell removes both characters and concatenates adjacent lines. A multi-line value such as a PROMPT_COMMAND set by bash-preexec would therefore be silently mangled, e.g. "... >/dev/null 2>&1\n__bp_interactive_mode" collapsed into "... 2>&1__bp_interactive_mode", producing the redirect target "1__bp_interactive_mode" and the error: bash: 1__bp_interactive_mode: ambiguous redirect Newlines are not special inside double quotes, so they must be left unescaped to be preserved literally. Add a regression test covering multi-line values alongside the existing special-character escaping. Fixes #2814 --- internal/devbox/envvars.go | 9 ++++++- internal/devbox/envvars_test.go | 44 +++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 internal/devbox/envvars_test.go diff --git a/internal/devbox/envvars.go b/internal/devbox/envvars.go index ec8231e19ab..50a4b71eba9 100644 --- a/internal/devbox/envvars.go +++ b/internal/devbox/envvars.go @@ -49,7 +49,14 @@ func exportify(vars map[string]string) string { switch r { // Special characters inside double quotes: // https://pubs.opengroup.org/onlinepubs/009604499/utilities/xcu_chap02.html#tag_02_02_03 - case '$', '`', '"', '\\', '\n': + // + // Note: 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. + 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) + } + }) + } +} From 429d0281ca11b4422e0dba697df899a2743d5e20 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 3 Jun 2026 14:13:14 +0000 Subject: [PATCH 2/2] fix lint: move comment above loop to keep loop variable scope short The varnamelen linter flagged the single-letter loop variable 'r' because the added explanatory comment lengthened the loop body scope. Move the comment above the for loop; behavior is unchanged. --- internal/devbox/envvars.go | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/internal/devbox/envvars.go b/internal/devbox/envvars.go index 50a4b71eba9..63d44769ead 100644 --- a/internal/devbox/envvars.go +++ b/internal/devbox/envvars.go @@ -45,17 +45,16 @@ 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 - // - // Note: 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. case '$', '`', '"', '\\': strb.WriteRune('\\') }