@@ -3,260 +3,221 @@ package config
33import (
44 "context"
55 "errors"
6+ "strings"
67 "testing"
78
89 "github.com/charmbracelet/crush/internal/env"
910 "github.com/stretchr/testify/require"
1011)
1112
12−// mockShell implements the Shell interface for testing
13−type mockShell struct {
14− execFunc func(ctx context.Context, command string) (stdout, stderr string, err error)
13+// fakeExpander returns a canned value/error for the last passed value and
14+// records the context, raw value, and env slice it was called with. It
15+// lets the config-layer tests assert on delegation behaviour without
16+// spinning up a real interpreter — real-shell coverage lives in
17+// internal/shell/expand_test.go and resolve_real_test.go.
18+type fakeExpander struct {
19+ expand func(ctx context.Context, value string, env []string) (string, error)
20+ lastValue string
21+ lastEnv []string
22+ calls int
1523}
1624
17−func (m *mockShell) Exec(ctx context.Context, command string) (stdout, stderr string, err error) {
18− if m.execFunc != nil {
19− return m.execFunc(ctx, command)
25+func (f *fakeExpander) Expand(ctx context.Context, value string, env []string) (string, error) {
26+ f.calls++
27+ f.lastValue = value
28+ f.lastEnv = env
29+ if f.expand == nil {
30+ return value, nil
2031 }
21− return "", "", nil
32+ return f.expand(ctx, value, env)
2233}
2334
24−func TestShellVariableResolver_ResolveValue(t *testing.T) {
25− tests := []struct {
26− name string
27− value string
28− envVars map[string]string
29− shellFunc func(ctx context.Context, command string) (stdout, stderr string, err error)
30− expected string
31− expectError bool
32− }{
33− {
34− name: "non-variable string returns as-is",
35− value: "plain-string",
36− expected: "plain-string",
37− },
38− {
39− name: "environment variable resolution",
40− value: "$HOME",
41− envVars: map[string]string{"HOME": "/home/user"},
42− expected: "/home/user",
43− },
44− {
45− name: "missing environment variable returns error",
46− value: "$MISSING_VAR",
47− envVars: map[string]string{},
48− expectError: true,
49− },
35+func TestShellVariableResolver_DelegatesToExpander(t *testing.T) {
36+ t.Parallel()
5037
51− {
52− name: "shell command with whitespace trimming",
53− value: "$(echo ' spaced ')",
54− shellFunc: func(ctx context.Context, command string) (stdout, stderr string, err error) {
55− if command == "echo ' spaced '" {
56− return " spaced \n", "", nil
57− }
58− return "", "", errors.New("unexpected command")
59− },
60− expected: "spaced",
61− },
62− {
63− name: "shell command execution error",
64− value: "$(false)",
65− shellFunc: func(ctx context.Context, command string) (stdout, stderr string, err error) {
66− return "", "", errors.New("command failed")
67− },
68− expectError: true,
69− },
70− {
71− name: "invalid format returns error",
72− value: "$",
73− expectError: true,
38+ fe := &fakeExpander{
39+ expand: func(_ context.Context, value string, _ []string) (string, error) {
40+ if value == "hello $FOO" {
41+ return "hello bar", nil
42+ }
43+ return value, nil
7444 },
7545 }
7646
77− for _, tt := range tests {
78− t.Run(tt.name, func(t *testing.T) {
79− testEnv := env.NewFromMap(tt.envVars)
80− resolver := &shellVariableResolver{
81− shell: &mockShell{execFunc: tt.shellFunc},
82− env: testEnv,
83− }
47+ e := env.NewFromMap(map[string]string{"FOO": "bar"})
48+ r := NewShellVariableResolver(e, WithExpander(fe.Expand))
8449
85− result, err := resolver.ResolveValue(tt.value)
50+ got, err := r.ResolveValue("hello $FOO")
51+ require.NoError(t, err)
52+ require.Equal(t, "hello bar", got)
53+ require.Equal(t, 1, fe.calls)
54+ require.Equal(t, "hello $FOO", fe.lastValue)
55+ require.Contains(t, fe.lastEnv, "FOO=bar")
56+}
8657
87− if tt.expectError {
88− require.Error(t, err)
89− } else {
90− require.NoError(t, err)
91− require.Equal(t, tt.expected, result)
92− }
93− })
94− }
58+func TestShellVariableResolver_LoneDollarIsError(t *testing.T) {
59+ t.Parallel()
60+
61+ // Lone "$" must short-circuit before reaching the expander: the
62+ // underlying shell parser would accept it as a literal, but this
63+ // resolver has historically rejected it and callers depend on
64+ // that early-fail behaviour.
65+ fe := &fakeExpander{}
66+ r := NewShellVariableResolver(env.NewFromMap(nil), WithExpander(fe.Expand))
67+
68+ _, err := r.ResolveValue("$")
69+ require.Error(t, err)
70+ require.Equal(t, 0, fe.calls, "expander must not be called for lone $")
9571}
9672
97−func TestShellVariableResolver_EnhancedResolveValue(t *testing.T) {
98− tests := []struct {
99− name string
100− value string
101− envVars map[string]string
102− shellFunc func(ctx context.Context, command string) (stdout, stderr string, err error)
103− expected string
104− expectError bool
105− }{
106− {
107− name: "command substitution within string",
108− value: "Bearer $(echo token123)",
109− shellFunc: func(ctx context.Context, command string) (stdout, stderr string, err error) {
110− if command == "echo token123" {
111− return "token123\n", "", nil
112− }
113− return "", "", errors.New("unexpected command")
114− },
115− expected: "Bearer token123",
116− },
117− {
118− name: "environment variable within string",
119− value: "Bearer $TOKEN",
120− envVars: map[string]string{"TOKEN": "sk-ant-123"},
121− expected: "Bearer sk-ant-123",
122− },
123− {
124− name: "environment variable with braces within string",
125− value: "Bearer ${TOKEN}",
126− envVars: map[string]string{"TOKEN": "sk-ant-456"},
127− expected: "Bearer sk-ant-456",
128− },
129− {
130− name: "mixed command and environment substitution",
131− value: "$USER-$(date +%Y)-$HOST",
132− envVars: map[string]string{
133− "USER": "testuser",
134− "HOST": "localhost",
135− },
136− shellFunc: func(ctx context.Context, command string) (stdout, stderr string, err error) {
137− if command == "date +%Y" {
138− return "2024\n", "", nil
139− }
140− return "", "", errors.New("unexpected command")
141− },
142− expected: "testuser-2024-localhost",
143− },
144− {
145− name: "multiple command substitutions",
146− value: "$(echo hello) $(echo world)",
147− shellFunc: func(ctx context.Context, command string) (stdout, stderr string, err error) {
148− switch command {
149− case "echo hello":
150− return "hello\n", "", nil
151− case "echo world":
152− return "world\n", "", nil
153− }
154− return "", "", errors.New("unexpected command")
155− },
156− expected: "hello world",
157− },
158− {
159− name: "nested parentheses in command",
160− value: "$(echo $(echo inner))",
161− shellFunc: func(ctx context.Context, command string) (stdout, stderr string, err error) {
162− if command == "echo $(echo inner)" {
163− return "nested\n", "", nil
164− }
165− return "", "", errors.New("unexpected command")
166− },
167− expected: "nested",
168− },
169− {
170− name: "lone dollar with non-variable chars",
171− value: "prefix$123suffix", // Numbers can't start variable names
172− expectError: true,
173− },
174− {
175− name: "dollar with special chars",
176− value: "a$@b$#c", // Special chars aren't valid in variable names
177− expectError: true,
178− },
179− {
180− name: "empty environment variable substitution",
181− value: "Bearer $EMPTY_VAR",
182− envVars: map[string]string{},
183− expectError: true,
184− },
185− {
186− name: "unmatched command substitution opening",
187− value: "Bearer $(echo test",
188− expectError: true,
189− },
190− {
191− name: "unmatched environment variable braces",
192− value: "Bearer ${TOKEN",
193− expectError: true,
194− },
195− {
196− name: "command substitution with error",
197− value: "Bearer $(false)",
198− shellFunc: func(ctx context.Context, command string) (stdout, stderr string, err error) {
199− return "", "", errors.New("command failed")
200− },
201− expectError: true,
202− },
203− {
204− name: "complex real-world example",
205− value: "Bearer $(cat /tmp/token.txt | base64 -w 0)",
206− shellFunc: func(ctx context.Context, command string) (stdout, stderr string, err error) {
207− if command == "cat /tmp/token.txt | base64 -w 0" {
208− return "c2stYW50LXRlc3Q=\n", "", nil
209− }
210− return "", "", errors.New("unexpected command")
211− },
212− expected: "Bearer c2stYW50LXRlc3Q=",
213− },
214− {
215− name: "environment variable with underscores and numbers",
216− value: "Bearer $API_KEY_V2",
217− envVars: map[string]string{"API_KEY_V2": "sk-test-123"},
218− expected: "Bearer sk-test-123",
219− },
220− {
221− name: "no substitution needed",
222− value: "Bearer sk-ant-static-token",
223− expected: "Bearer sk-ant-static-token",
224− },
225− {
226− name: "incomplete variable at end",
227− value: "Bearer $",
228− expectError: true,
229− },
230− {
231− name: "variable with invalid character",
232− value: "Bearer $VAR-NAME", // Hyphen not allowed in variable names
233− expectError: true,
234− },
235− {
236− name: "multiple invalid variables",
237− value: "$1$2$3",
238− expectError: true,
73+func TestShellVariableResolver_PassesThroughLiterals(t *testing.T) {
74+ t.Parallel()
75+
76+ fe := &fakeExpander{
77+ expand: func(_ context.Context, value string, _ []string) (string, error) {
78+ return value, nil
23979 },
24080 }
81+ r := NewShellVariableResolver(env.NewFromMap(nil), WithExpander(fe.Expand))
24182
242− for _, tt := range tests {
243− t.Run(tt.name, func(t *testing.T) {
244− testEnv := env.NewFromMap(tt.envVars)
245− resolver := &shellVariableResolver{
246− shell: &mockShell{execFunc: tt.shellFunc},
247− env: testEnv,
248− }
83+ got, err := r.ResolveValue("plain-string")
84+ require.NoError(t, err)
85+ require.Equal(t, "plain-string", got)
86+}
24987
250− result, err := resolver.ResolveValue(tt.value)
88+func TestShellVariableResolver_WrapsErrorsWithTemplate(t *testing.T) {
89+ t.Parallel()
25190
252− if tt.expectError {
253− require.Error(t, err)
254− } else {
255− require.NoError(t, err)
256− require.Equal(t, tt.expected, result)
257− }
258− })
91+ inner := errors.New("cat: /run/secrets/x: permission denied")
92+ fe := &fakeExpander{
93+ expand: func(_ context.Context, _ string, _ []string) (string, error) {
94+ return "", inner
95+ },
25996 }
97+ r := NewShellVariableResolver(env.NewFromMap(nil), WithExpander(fe.Expand))
98+
99+ _, err := r.ResolveValue("$(cat /run/secrets/x)")
100+ require.Error(t, err)
101+ require.ErrorIs(t, err, inner)
102+ require.Contains(t, err.Error(), "$(cat /run/secrets/x)")
103+ require.Contains(t, err.Error(), "permission denied")
104+}
105+
106+func TestSanitizeResolveError(t *testing.T) {
107+ t.Parallel()
108+
109+ t.Run("nil passes through", func(t *testing.T) {
110+ t.Parallel()
111+ require.NoError(t, sanitizeResolveError("anything", nil))
112+ })
113+
114+ t.Run("includes template and wraps inner", func(t *testing.T) {
115+ t.Parallel()
116+ inner := errors.New("cat: /run/secrets/x: permission denied")
117+ got := sanitizeResolveError("$(cat /run/secrets/x)", inner)
118+ require.Error(t, got)
119+ require.ErrorIs(t, got, inner)
120+ require.Contains(t, got.Error(), "$(cat /run/secrets/x)")
121+ require.Contains(t, got.Error(), "permission denied")
122+ })
123+
124+ t.Run("unwrap preserves original for errors.Is", func(t *testing.T) {
125+ t.Parallel()
126+ inner := errors.New("sentinel")
127+ got := sanitizeResolveError("$FOO", inner)
128+ require.ErrorIs(t, got, inner)
129+ })
130+
131+ t.Run("truncates over-budget inner message", func(t *testing.T) {
132+ t.Parallel()
133+ // Inner message holds far more than the budget. After
134+ // sanitization the rendered inner portion must not exceed
135+ // maxResolveErrBytes, and the characters beyond the budget
136+ // (marked by a distinct tail sentinel) must be gone.
137+ const tailSentinel = "TAIL_SENTINEL_BEYOND_BUDGET"
138+ body := strings.Repeat("x", maxResolveErrBytes)
139+ inner := errors.New(body + tailSentinel)
140+
141+ got := sanitizeResolveError("$TEMPLATE", inner)
142+ require.Error(t, got)
143+
144+ prefix := `resolving "$TEMPLATE": `
145+ rendered := got.Error()
146+ require.True(
147+ t,
148+ strings.HasPrefix(rendered, prefix),
149+ "rendered error must start with template prefix",
150+ )
151+ innerRendered := strings.TrimPrefix(rendered, prefix)
152+ require.LessOrEqual(
153+ t,
154+ len(innerRendered),
155+ maxResolveErrBytes,
156+ "inner message must be bounded to maxResolveErrBytes",
157+ )
158+ require.NotContains(
159+ t,
160+ rendered,
161+ tailSentinel,
162+ "content past the budget must not leak",
163+ )
164+ })
165+
166+ t.Run("replaces non-printable bytes", func(t *testing.T) {
167+ t.Parallel()
168+ // NUL, BEL, ESC, DEL, and a UTF-8 high byte should all be
169+ // scrubbed to '?'. Tab and newline are preserved because
170+ // they show up legitimately in command stderr.
171+ inner := errors.New("ok\x00bad\x07\x1b\x7f\xffend\ttab\nline")
172+ got := sanitizeResolveError("$T", inner)
173+ rendered := got.Error()
174+
175+ require.NotContains(t, rendered, "\x00")
176+ require.NotContains(t, rendered, "\x07")
177+ require.NotContains(t, rendered, "\x1b")
178+ require.NotContains(t, rendered, "\x7f")
179+ require.NotContains(t, rendered, "\xff")
180+ require.Contains(t, rendered, "ok?bad????end\ttab\nline")
181+ })
182+
183+ t.Run("scrubbing does not depend on shell.ExpandValue upstream", func(t *testing.T) {
184+ t.Parallel()
185+ // A custom Expander can inject arbitrary error text. The
186+ // config-layer helper is the single chokepoint; it must
187+ // bound + scrub regardless of the error source.
188+ nasty := strings.Repeat("A", maxResolveErrBytes+64) + "\x00BEYOND"
189+ fe := &fakeExpander{
190+ expand: func(_ context.Context, _ string, _ []string) (string, error) {
191+ return "", errors.New(nasty)
192+ },
193+ }
194+ r := NewShellVariableResolver(env.NewFromMap(nil), WithExpander(fe.Expand))
195+
196+ _, err := r.ResolveValue("$T")
197+ require.Error(t, err)
198+ require.NotContains(t, err.Error(), "BEYOND", "over-budget tail must not leak")
199+ require.NotContains(t, err.Error(), "\x00", "non-printables must be scrubbed")
200+ })
201+}
202+
203+func TestScrubErrorMessage(t *testing.T) {
204+ t.Parallel()
205+
206+ t.Run("bounds output to maxResolveErrBytes", func(t *testing.T) {
207+ t.Parallel()
208+ got := scrubErrorMessage(strings.Repeat("a", maxResolveErrBytes*3))
209+ require.Len(t, got, maxResolveErrBytes)
210+ })
211+
212+ t.Run("preserves printable ASCII tab and newline", func(t *testing.T) {
213+ t.Parallel()
214+ require.Equal(t, "a\tb\nc d!", scrubErrorMessage("a\tb\nc d!"))
215+ })
216+
217+ t.Run("replaces control and non-ASCII bytes", func(t *testing.T) {
218+ t.Parallel()
219+ require.Equal(t, "a?b??c", scrubErrorMessage("a\x01b\x1b\xe2c"))
220+ })
260221}
261222
262223func TestEnvironmentVariableResolver_ResolveValue(t *testing.T) {