@@ -0,0 +1,199 @@
1+package shell
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func 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+
105+func TestExpandValue_Errors(t *testing.T) {
106+ t.Parallel()
107+
108+ t.Run("unset var is an error, not empty", func(t *testing.T) {
109+ t.Parallel()
110+ _, err := ExpandValue(t.Context(), "$MISSING", nil)
111+ require.Error(t, err)
112+ })
113+
114+ t.Run("unset var inside braces is an error", func(t *testing.T) {
115+ t.Parallel()
116+ _, err := ExpandValue(t.Context(), "${MISSING}", nil)
117+ require.Error(t, err)
118+ })
119+
120+ t.Run("unset var inside cmdsubst is an error", func(t *testing.T) {
121+ t.Parallel()
122+ _, err := ExpandValue(t.Context(), `$(printf '%s' "$MISSING")`, nil)
123+ require.Error(t, err)
124+ })
125+
126+ t.Run("bad syntax returns error", func(t *testing.T) {
127+ t.Parallel()
128+ _, err := ExpandValue(t.Context(), "$(", nil)
129+ require.Error(t, err)
130+ })
131+
132+ t.Run("inner non-zero exit returns error with exit code", func(t *testing.T) {
133+ t.Parallel()
134+ _, err := ExpandValue(t.Context(), "$(false)", nil)
135+ require.Error(t, err)
136+ require.Contains(t, err.Error(), "exit status 1")
137+ })
138+
139+ t.Run("inner explicit exit code is surfaced", func(t *testing.T) {
140+ t.Parallel()
141+ _, err := ExpandValue(t.Context(), "$(exit 7)", nil)
142+ require.Error(t, err)
143+ require.Contains(t, err.Error(), "exit status 7")
144+ })
145+
146+ t.Run("inner stderr is surfaced", func(t *testing.T) {
147+ t.Parallel()
148+ _, err := ExpandValue(
149+ t.Context(),
150+ `$(printf 'boom\n' 1>&2; exit 1)`,
151+ nil,
152+ )
153+ require.Error(t, err)
154+ require.Contains(t, err.Error(), "boom")
155+ })
156+
157+ t.Run("inner stderr is truncated to byte budget", func(t *testing.T) {
158+ t.Parallel()
159+ // Emit more than maxInnerStderrBytes bytes of 'X' on stderr.
160+ long := strings.Repeat("X", maxInnerStderrBytes*2)
161+ _, err := ExpandValue(
162+ t.Context(),
163+ `$(printf '`+long+`' 1>&2; exit 1)`,
164+ nil,
165+ )
166+ require.Error(t, err)
167+ require.NotContains(
168+ t,
169+ err.Error(),
170+ strings.Repeat("X", maxInnerStderrBytes+1),
171+ "stderr should be bounded",
172+ )
173+ })
174+}
175+
176+func TestSanitizeStderr(t *testing.T) {
177+ t.Parallel()
178+
179+ t.Run("trims trailing newlines", func(t *testing.T) {
180+ t.Parallel()
181+ require.Equal(t, "hi", sanitizeStderr([]byte("hi\n\n")))
182+ })
183+
184+ t.Run("preserves tabs and embedded newlines", func(t *testing.T) {
185+ t.Parallel()
186+ require.Equal(t, "a\tb\nc", sanitizeStderr([]byte("a\tb\nc")))
187+ })
188+
189+ t.Run("replaces control characters", func(t *testing.T) {
190+ t.Parallel()
191+ require.Equal(t, "a?b", sanitizeStderr([]byte{'a', 0x01, 'b'}))
192+ })
193+
194+ t.Run("bounds output", func(t *testing.T) {
195+ t.Parallel()
196+ got := sanitizeStderr([]byte(strings.Repeat("x", maxInnerStderrBytes*2)))
197+ require.Len(t, got, maxInnerStderrBytes)
198+ })
199+}