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}