expand_test.go

  1package shell
  2
  3import (
  4	"strings"
  5	"testing"
  6
  7	"github.com/stretchr/testify/require"
  8)
  9
 10func TestExpandValue_Success(t *testing.T) {
 11	t.Parallel()
 12
 13	tests := []struct {
 14		name  string
 15		value string
 16		env   []string
 17		want  string
 18	}{
 19		{
 20			name:  "plain string round trip",
 21			value: "hello world",
 22			want:  "hello world",
 23		},
 24		{
 25			name:  "plain var from env",
 26			value: "$FOO",
 27			env:   []string{"FOO=bar"},
 28			want:  "bar",
 29		},
 30		{
 31			name:  "braced var from env",
 32			value: "pre-${FOO}-post",
 33			env:   []string{"FOO=bar"},
 34			want:  "pre-bar-post",
 35		},
 36		{
 37			name:  "default syntax on unset",
 38			value: "${MISSING:-fallback}",
 39			want:  "fallback",
 40		},
 41		{
 42			name:  "default syntax on set preserves value",
 43			value: "${SET:-fallback}",
 44			env:   []string{"SET=used"},
 45			want:  "used",
 46		},
 47		{
 48			name:  "command substitution",
 49			value: "$(echo hi)",
 50			want:  "hi",
 51		},
 52		{
 53			name:  "command substitution preserves internal spaces",
 54			value: `$(echo "a b")`,
 55			want:  "a b",
 56		},
 57		{
 58			name:  "command substitution strips only trailing newline",
 59			value: "$(printf 'a\\nb\\n')",
 60			want:  "a\nb",
 61		},
 62		{
 63			name:  "literal spaces around cmdsubst are preserved",
 64			value: "  $(echo v)  ",
 65			want:  "  v  ",
 66		},
 67		{
 68			name:  "paren inside quoted arg to echo",
 69			value: `$(echo ")")`,
 70			want:  ")",
 71		},
 72		{
 73			name:  "nested command substitution",
 74			value: "$(echo $(echo hi))",
 75			want:  "hi",
 76		},
 77		{
 78			name:  "glob-like input round trips unchanged",
 79			value: "*.go",
 80			want:  "*.go",
 81		},
 82		{
 83			name:  "tilde round trips unchanged",
 84			value: "~",
 85			want:  "~",
 86		},
 87		{
 88			name:  "env var inside cmdsubst",
 89			value: `$(printf '%s' "$FOO")`,
 90			env:   []string{"FOO=bar"},
 91			want:  "bar",
 92		},
 93	}
 94
 95	for _, tc := range tests {
 96		t.Run(tc.name, func(t *testing.T) {
 97			t.Parallel()
 98			got, err := ExpandValue(t.Context(), tc.value, tc.env)
 99			require.NoError(t, err)
100			require.Equal(t, tc.want, got)
101		})
102	}
103}
104
105func TestExpandValue_Errors(t *testing.T) {
106	t.Parallel()
107
108	t.Run("unset var expands to empty under lenient default", func(t *testing.T) {
109		t.Parallel()
110		got, err := ExpandValue(t.Context(), "$MISSING", nil)
111		require.NoError(t, err)
112		require.Equal(t, "", got)
113	})
114
115	t.Run("unset var inside braces expands to empty", func(t *testing.T) {
116		t.Parallel()
117		got, err := ExpandValue(t.Context(), "${MISSING}", nil)
118		require.NoError(t, err)
119		require.Equal(t, "", got)
120	})
121
122	t.Run("unset var inside cmdsubst expands to empty", func(t *testing.T) {
123		t.Parallel()
124		got, err := ExpandValue(t.Context(), `$(printf '%s' "$MISSING")`, nil)
125		require.NoError(t, err)
126		require.Equal(t, "", got)
127	})
128
129	t.Run("bad syntax returns error", func(t *testing.T) {
130		t.Parallel()
131		_, err := ExpandValue(t.Context(), "$(", nil)
132		require.Error(t, err)
133	})
134
135	t.Run("inner non-zero exit returns error with exit code", func(t *testing.T) {
136		t.Parallel()
137		_, err := ExpandValue(t.Context(), "$(false)", nil)
138		require.Error(t, err)
139		require.Contains(t, err.Error(), "exit status 1")
140	})
141
142	t.Run("inner explicit exit code is surfaced", func(t *testing.T) {
143		t.Parallel()
144		_, err := ExpandValue(t.Context(), "$(exit 7)", nil)
145		require.Error(t, err)
146		require.Contains(t, err.Error(), "exit status 7")
147	})
148
149	t.Run("inner stderr is surfaced", func(t *testing.T) {
150		t.Parallel()
151		_, err := ExpandValue(
152			t.Context(),
153			`$(printf 'boom\n' 1>&2; exit 1)`,
154			nil,
155		)
156		require.Error(t, err)
157		require.Contains(t, err.Error(), "boom")
158	})
159
160	t.Run("inner stderr is truncated to byte budget", func(t *testing.T) {
161		t.Parallel()
162		// Emit more than maxInnerStderrBytes bytes of 'X' on stderr.
163		long := strings.Repeat("X", maxInnerStderrBytes*2)
164		_, err := ExpandValue(
165			t.Context(),
166			`$(printf '`+long+`' 1>&2; exit 1)`,
167			nil,
168		)
169		require.Error(t, err)
170		require.NotContains(
171			t,
172			err.Error(),
173			strings.Repeat("X", maxInnerStderrBytes+1),
174			"stderr should be bounded",
175		)
176	})
177}
178
179// TestExpandValue_StrictToggle pins the NoUnset escape hatch: when a
180// caller flips strict mode on, bare $UNSET must error instead of
181// expanding to the empty string. Must not run in parallel: it mutates
182// the package-level NoUnset atomic, so a parallel peer observing the
183// flipped value would break the lenient default other tests assume.
184func TestExpandValue_StrictToggle(t *testing.T) {
185	NoUnset.Store(true)
186	t.Cleanup(func() { NoUnset.Store(false) })
187
188	_, err := ExpandValue(t.Context(), "$UNSET", nil)
189	require.Error(t, err)
190
191	_, err = ExpandValue(t.Context(), "${UNSET}", nil)
192	require.Error(t, err)
193
194	_, err = ExpandValue(t.Context(), `$(printf '%s' "$UNSET")`, nil)
195	require.Error(t, err)
196}
197
198// TestExpandValue_RequiredOptIn pins the per-reference opt-in strict
199// idiom ${VAR:?msg}: it must error whether or not the global NoUnset
200// toggle is on, so config authors can mark individual credentials as
201// required without flipping the global default.
202func TestExpandValue_RequiredOptIn(t *testing.T) {
203	t.Parallel()
204
205	_, err := ExpandValue(t.Context(), "${REQUIRED:?must be set}", nil)
206	require.Error(t, err)
207	require.Contains(t, err.Error(), "must be set")
208
209	got, err := ExpandValue(
210		t.Context(),
211		"${REQUIRED:?must be set}",
212		[]string{"REQUIRED=ok"},
213	)
214	require.NoError(t, err)
215	require.Equal(t, "ok", got)
216}
217
218func TestSanitizeStderr(t *testing.T) {
219	t.Parallel()
220
221	t.Run("trims trailing newlines", func(t *testing.T) {
222		t.Parallel()
223		require.Equal(t, "hi", sanitizeStderr([]byte("hi\n\n")))
224	})
225
226	t.Run("preserves tabs and embedded newlines", func(t *testing.T) {
227		t.Parallel()
228		require.Equal(t, "a\tb\nc", sanitizeStderr([]byte("a\tb\nc")))
229	})
230
231	t.Run("replaces control characters", func(t *testing.T) {
232		t.Parallel()
233		require.Equal(t, "a?b", sanitizeStderr([]byte{'a', 0x01, 'b'}))
234	})
235
236	t.Run("bounds output", func(t *testing.T) {
237		t.Parallel()
238		got := sanitizeStderr([]byte(strings.Repeat("x", maxInnerStderrBytes*2)))
239		require.Len(t, got, maxInnerStderrBytes)
240	})
241}